fix(threads): stop crossing thread boundaries in the RX and TX audio paths - #57
Merged
Conversation
…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>
# Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 isnarrow, and each carries a test that fails without it.
1. The audio-device swap reset the decoder from the wrong thread
_swap_audio_workercalledRxWorker.reset()directly from the GUIthread. The audio worker's
stoppedsignal fans out to both_rx_worker.flush(queued → RX thread) and the swap loop'squit(GUI), sothe GUI could wake and reset the decoder while a flush was still inside
Decoder.feed(), clearing_buffer/_scratch/_total_samplesunder alive decode.
Worse,
reset()lazily creates the RX watchdogQTimer, which takes theaffinity 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 alreadydeclared 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_timernow refuses to create the timer when the callerisn'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_oncewas a bare closure. A plain callable has noQObject affinity, so PySide invokes it in the emitting thread — the RX
decode thread — where it called
sd.query_devices(), a process-globalPortAudio read that can run concurrently with the audio worker's own
_pa_reset, and wrote_input_devicewhile the GUI thread read it.Now a real slot on
MainWindow, connected queued at construction, with anarmed 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 closedIt read
_active_streamunder the lock, released it, then calledabort(). The TX thread clears that pointer under the same lock and onlythen lets its
with sd.OutputStream(...)block runPa_StopStream/Pa_CloseStream— so the released window letPa_AbortStreamland on a stream mid-close. sounddevice passes the pointerstraight to PortAudio with no state check, and
close()frees beforeNULLing. 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, soholding it is cheap.
Tests
Two regression tests, both verified to fail without their fix. The swap test
detaches
_request_rx_resetfrom the worker's slot first, so the only wayreset()can be reached is a direct call — otherwise the spy can't tell thetwo apart.
137 passed across
tests/ui/test_stability_fixes.py,test_rx_worker.pyandtests/audio; ruff clean.Not addressed here
The review also flagged nested event loops letting
closeEventre-enter, and_abort_offline_workersblocking up to 20 s then callingterminate()on athread 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