Skip to content

fix(audio): move blocking cpal work off the main thread + lock-free is_recording#1716

Open
xronocode wants to merge 1 commit into
cjpais:mainfrom
xronocode:fix/main-thread-audio-hang
Open

fix(audio): move blocking cpal work off the main thread + lock-free is_recording#1716
xronocode wants to merge 1 commit into
cjpais:mainfrom
xronocode:fix/main-thread-audio-hang

Conversation

@xronocode

Copy link
Copy Markdown

Before Submitting This PR

(Duplicate search done before opening #1715: no earlier open or closed issue/PR addresses the intermittent UI beachball while audio devices open/close. #1354 (poisoned mutexes in Drop impls) is a different problem: it handles panic-during-Drop on quit, not the live main-thread hang during recording. This PR targets the main-thread Mutex-across-CoreAudio contention.)

Human Written Description

I noticed Handy could intermittently beachball while recording, most often when using Bluetooth/USB microphones or changing audio devices. In the worst case the app stopped responding and had to be force-quit.

This change moves the blocking audio work away from UI-facing command paths and makes the recording-state poll lock-free, so normal recording/device operations should not freeze the app.

Related Issues/Discussions

Fixes #1715

Community Feedback

Bug fix: a production UI freeze during recording, amplified by Bluetooth/USB microphones. Tracked in #1715; not previously reported upstream as a distinct issue before this bug report. Sibling to #1644.

Testing

Root cause (class): synchronous #[tauri::command] pub fn ... runs inline on the webview/main run loop. The audio manager guards its state with std::sync::Mutex (blocks the OS thread, no timeout) and holds it across blocking CoreAudio syscalls (cpal stream start/stop in try_start_recording / schedule_lazy_close, and device enumeration). Any worker holding that mutex across a slow device open/close serializes the main thread, causing deadlock/freeze. The frontend polls is_recording(), which locked state, the most-polled main-thread contender.

Two manifestations, both still present on main @ d861e24:

  • (A) is_recording() locked state (src-tauri/src/managers/audio.rs, is_recording).
  • (B) four cpal-running commands were synchronous pub fn on the main run loop: update_microphone_mode, get_available_microphones, set_selected_microphone, get_available_output_devices (src-tauri/src/commands/audio.rs).

What changed:

  • Fix A (managers/audio.rs): added a lock-free recording_active: Arc<AtomicBool> mirroring the "state in {Recording, Stopping}" membership, flipped at the state transitions. is_recording() reads the atomic instead of taking the state mutex. (Note: main now has a Stopping intermediate variant in RecordingState, and is_recording() already returned true for Recording | Stopping; the atomic faithfully mirrors both: flipped true on Idle to Recording, false on Stopping to Idle and on cancel Recording to Idle. The Recording to Stopping transition needs no flip because Stopping stays in the active set.)
  • Fix B (commands/audio.rs): the four cpal-running commands are now async and run their blocking cpal work via tokio::task::spawn_blocking. Settings reads/writes stay inline (fast). #[specta::specta] and all return types are preserved; Tauri's invoke is identical for sync/async commands, so there is no frontend/binding change (bindings regenerated cleanly).

Build verification (on main @ d861e24, branch fix/main-thread-audio-hang):

  • cargo check --locked to 0 errors
  • cargo clippy --locked to no new lints (only one pre-existing warning: unused assignment model_takes_initial_prompt in transcription.rs:1168, unrelated to this change)
  • cargo fmt --check to clean

Scope: 2 files, shared code only: managers/audio.rs (Fix A: new atomic field + 3 flip sites + lock-free is_recording), commands/audio.rs (Fix B: 4 sync pub fn to async + spawn_blocking). No recording semantics changed; no frontend, bindings, or other managers touched.

Manual verification needed (hardware/concurrency, not unit-testable):

  1. Build + clippy + tsc (bindings) green.
  2. Record push-to-talk repeatedly (including with a Bluetooth/USB mic); the UI must never beachball while a stream opens/closes.
  3. Toggle mic mode (Always-On to On-Demand) mid-session; change input device mid-recording; open Settings then Audio (device enumeration) mid-recording. No freeze in any case.

Known follow-up (intentionally NOT in this PR): try_start_recording / schedule_lazy_close still hold state across the cpal open/close, but after A+B only worker threads contend there (no main-thread hot path remains), so no UI freeze. Restructuring them is a recording-semantics risk for no user-visible gain.

Screenshots/Videos (if applicable)

Native beachball produces no static screenshot; repro is "record with a Bluetooth/USB mic, change device or toggle mic mode mid-session, and the UI freezes". Happy to capture a no-freeze video on request.

AI Assistance

  • AI was used (please describe below)

If AI was used:

  • Tools used: Claude (Kilo CLI) for root-cause analysis, patch porting, and build/clippy/fmt verification; the same fixes were independently shipped and verified in production in a downstream fork (TokMo).
  • How extensively: AI assisted with porting the two fixes onto current main (adapting Fix A to main's newer RecordingState::Stopping variant) and running the verification. The hang reproduction and the decision to move cpal work off the main thread were driven by real Bluetooth/USB-mic testing in the fork.

…s_recording

Synchronous #[tauri::command] handlers run inline on the webview/main run
loop, and the audio manager guards its state with a std Mutex held across
blocking CoreAudio syscalls (cpal stream start/stop, device enumeration).
A worker holding that mutex across a slow device open/close (Bluetooth/USB
mic) serializes the main thread, freezing the UI (spinning beachball).

Fix A: is_recording() reads a lock-free Arc<AtomicBool> mirror of the
"state in {Recording, Stopping}" membership, flipped at the state
transitions, instead of locking `state`. The hot-path UI poll can no longer
deadlock against a worker holding `state`.

Fix B: the four cpal-running commands (update_microphone_mode,
get_available_microphones, set_selected_microphone,
get_available_output_devices) become async and run their blocking cpal work
via tokio::task::spawn_blocking. Tauri's invoke is identical for sync/async
commands, so there is no frontend/binding change.

Live verification (Bluetooth/USB mic recording + device change mid-use) is
left to manual testing; concurrency/hardware behavior is not unit-testable.
@cjpais

cjpais commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Thanks I will take a look at this soon, I appreciate you submitting the fix and issue

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.

[BUG] UI can beachball while opening/closing audio devices during recording

2 participants