fix: transcription lock-up race condition & add small debounce (#824)

* fix: add TranscriptionState to prevent race conditions & add debounce

* refactor(transcription): replace state machine with actor coordinator

* some cleanup

* format

* minor cleanup

* format

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
johnpyp 2026-02-16 09:18:50 -05:00 committed by GitHub
parent dc2077431a
commit 7fdde63460
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 234 additions and 121 deletions

View file

@ -10,7 +10,7 @@ use crate::tray::{change_tray_icon, TrayIconState};
use crate::utils::{
self, show_processing_overlay, show_recording_overlay, show_transcribing_overlay,
};
use crate::ManagedToggleState;
use crate::TranscriptionCoordinator;
use ferrous_opencc::{config::BuiltinConfig, OpenCC};
use log::{debug, error};
use once_cell::sync::Lazy;
@ -20,6 +20,17 @@ use std::time::Instant;
use tauri::AppHandle;
use tauri::Manager;
/// Drop guard that notifies the [`TranscriptionCoordinator`] when the
/// transcription pipeline finishes — whether it completes normally or panics.
struct FinishGuard(AppHandle);
impl Drop for FinishGuard {
fn drop(&mut self) {
if let Some(c) = self.0.try_state::<TranscriptionCoordinator>() {
c.notify_processing_finished();
}
}
}
// Shortcut Action Trait
pub trait ShortcutAction: Send + Sync {
fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str);
@ -305,6 +316,7 @@ impl ShortcutAction for TranscribeAction {
let post_process = self.post_process;
tauri::async_runtime::spawn(async move {
let _guard = FinishGuard(ah.clone());
let binding_id = binding_id.clone(); // Clone for the inner async task
debug!(
"Starting async transcription task for binding: {}",
@ -423,11 +435,6 @@ impl ShortcutAction for TranscribeAction {
utils::hide_recording_overlay(&ah);
change_tray_icon(&ah, TrayIconState::Idle);
}
// Clear toggle state now that transcription is complete
if let Ok(mut states) = ah.state::<ManagedToggleState>().lock() {
states.active_toggles.insert(binding_id, false);
}
});
debug!(

View file

@ -13,6 +13,7 @@ mod overlay;
mod settings;
mod shortcut;
mod signal_handle;
mod transcription_coordinator;
mod tray;
mod tray_i18n;
mod utils;
@ -28,10 +29,10 @@ use managers::transcription::TranscriptionManager;
use signal_hook::consts::SIGUSR2;
#[cfg(unix)]
use signal_hook::iterator::Signals;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use tauri::image::Image;
pub use transcription_coordinator::TranscriptionCoordinator;
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Listener, Manager};
@ -78,14 +79,6 @@ fn build_console_filter() -> env_filter::Filter {
builder.build()
}
#[derive(Default)]
struct ShortcutToggleStates {
// Map: shortcut_binding_id -> is_active
active_toggles: HashMap<String, bool>,
}
type ManagedToggleState = Mutex<ShortcutToggleStates>;
fn show_main_window(app: &AppHandle) {
if let Some(main_window) = app.get_webview_window("main") {
// First, ensure the window is visible
@ -407,7 +400,6 @@ pub fn run() {
MacosLauncher::LaunchAgent,
Some(vec![]),
))
.manage(Mutex::new(ShortcutToggleStates::default()))
.setup(move |app| {
let settings = get_settings(&app.handle());
let tauri_log_level: tauri_plugin_log::LogLevel = settings.log_level.into();
@ -415,6 +407,7 @@ pub fn run() {
// Store the file log level in the atomic for the filter to use
FILE_LOG_LEVEL.store(file_log_level.to_level_filter() as u8, Ordering::Relaxed);
let app_handle = app.handle().clone();
app.manage(TranscriptionCoordinator::new(app_handle.clone()));
initialize_core_logic(&app_handle);

View file

@ -10,7 +10,8 @@ use tauri::{AppHandle, Manager};
use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager;
use crate::settings::get_settings;
use crate::ManagedToggleState;
use crate::transcription_coordinator::is_transcribe_binding;
use crate::TranscriptionCoordinator;
/// Handle a shortcut event from either implementation.
///
@ -33,6 +34,16 @@ pub fn handle_shortcut_event(
) {
let settings = get_settings(app);
// Transcribe bindings are handled by the coordinator.
if is_transcribe_binding(binding_id) {
if let Some(coordinator) = app.try_state::<TranscriptionCoordinator>() {
coordinator.send_input(binding_id, hotkey_string, is_pressed, settings.push_to_talk);
} else {
warn!("TranscriptionCoordinator is not initialized");
}
return;
}
let Some(action) = ACTION_MAP.get(binding_id) else {
warn!(
"No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', Pressed: {}",
@ -50,42 +61,10 @@ pub fn handle_shortcut_event(
return;
}
// Push-to-talk mode: start on press, stop on release
if settings.push_to_talk {
if is_pressed {
action.start(app, binding_id, hotkey_string);
} else {
action.stop(app, binding_id, hotkey_string);
}
return;
}
// Toggle mode: toggle state on press only
// Remaining bindings (e.g. "test") use simple start/stop on press/release.
if is_pressed {
// Determine action and update state while holding the lock,
// but RELEASE the lock before calling the action to avoid deadlocks.
// (Actions may need to acquire the lock themselves, e.g., cancel_current_operation)
let should_start: bool;
{
let toggle_state_manager = app.state::<ManagedToggleState>();
let mut states = toggle_state_manager
.lock()
.expect("Failed to lock toggle state manager");
let is_currently_active = states
.active_toggles
.entry(binding_id.to_string())
.or_insert(false);
should_start = !*is_currently_active;
*is_currently_active = should_start;
} // Lock released here
// Now call the action without holding the lock
if should_start {
action.start(app, binding_id, hotkey_string);
} else {
action.stop(app, binding_id, hotkey_string);
}
action.start(app, binding_id, hotkey_string);
} else {
action.stop(app, binding_id, hotkey_string);
}
}

View file

@ -1,6 +1,5 @@
use crate::actions::ACTION_MAP;
use crate::ManagedToggleState;
use log::{debug, info, warn};
use crate::TranscriptionCoordinator;
use log::{debug, warn};
use std::thread;
use tauri::{AppHandle, Manager};
@ -11,62 +10,16 @@ use signal_hook::iterator::Signals;
#[cfg(unix)]
pub fn setup_signal_handler(app_handle: AppHandle, mut signals: Signals) {
let app_handle_for_signal = app_handle.clone();
debug!("SIGUSR2 signal handler registered successfully");
debug!("SIGUSR2 signal handler registered");
thread::spawn(move || {
debug!("SIGUSR2 signal handler thread started");
for sig in signals.forever() {
match sig {
SIGUSR2 => {
debug!("Received SIGUSR2 signal (signal number: {sig})");
let binding_id = "transcribe";
let shortcut_string = "SIGUSR2";
if let Some(action) = ACTION_MAP.get(binding_id) {
// Determine action and update state while holding the lock,
// but RELEASE the lock before calling the action to avoid deadlocks.
// (Actions may need to acquire the lock themselves, e.g., cancel_current_operation)
let should_start: bool;
{
let toggle_state_manager =
app_handle_for_signal.state::<ManagedToggleState>();
let mut states = match toggle_state_manager.lock() {
Ok(s) => s,
Err(e) => {
warn!("Failed to lock toggle state manager: {e}");
continue;
}
};
let is_currently_active = states
.active_toggles
.entry(binding_id.to_string())
.or_insert(false);
should_start = !*is_currently_active;
if should_start {
*is_currently_active = true;
}
} // Lock released here
// Now call the action without holding the lock
if should_start {
debug!("SIGUSR2: Starting transcription (was inactive)");
action.start(&app_handle_for_signal, binding_id, shortcut_string);
info!("SIGUSR2: Transcription started");
} else {
debug!("SIGUSR2: Stopping transcription (was active)");
action.stop(&app_handle_for_signal, binding_id, shortcut_string);
debug!("SIGUSR2: Transcription stopped");
}
} else {
warn!("No action defined in ACTION_MAP for binding ID '{binding_id}'");
}
if sig == SIGUSR2 {
debug!("Received SIGUSR2");
if let Some(c) = app_handle.try_state::<TranscriptionCoordinator>() {
c.send_input("transcribe", "SIGUSR2", true, false);
} else {
warn!("TranscriptionCoordinator not initialized");
}
_ => unreachable!(),
}
}
});

View file

@ -0,0 +1,184 @@
use crate::actions::ACTION_MAP;
use crate::managers::audio::AudioRecordingManager;
use log::{debug, error, warn};
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager};
const DEBOUNCE: Duration = Duration::from_millis(30);
/// Commands processed sequentially by the coordinator thread.
enum Command {
Input {
binding_id: String,
hotkey_string: String,
is_pressed: bool,
push_to_talk: bool,
},
Cancel {
recording_was_active: bool,
},
ProcessingFinished,
}
/// Pipeline lifecycle, owned exclusively by the coordinator thread.
enum Stage {
Idle,
Recording(String), // binding_id
Processing,
}
/// Serialises all transcription lifecycle events through a single thread
/// to eliminate race conditions between keyboard shortcuts, signals, and
/// the async transcribe-paste pipeline.
pub struct TranscriptionCoordinator {
tx: Sender<Command>,
}
pub fn is_transcribe_binding(id: &str) -> bool {
id == "transcribe" || id == "transcribe_with_post_process"
}
impl TranscriptionCoordinator {
pub fn new(app: AppHandle) -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut stage = Stage::Idle;
let mut last_press: Option<Instant> = None;
while let Ok(cmd) = rx.recv() {
match cmd {
Command::Input {
binding_id,
hotkey_string,
is_pressed,
push_to_talk,
} => {
// Debounce rapid-fire press events (key repeat / double-tap).
// Releases always pass through for push-to-talk.
if is_pressed {
let now = Instant::now();
if last_press.map_or(false, |t| now.duration_since(t) < DEBOUNCE) {
debug!("Debounced press for '{binding_id}'");
continue;
}
last_press = Some(now);
}
if push_to_talk {
if is_pressed && matches!(stage, Stage::Idle) {
start(&app, &mut stage, &binding_id, &hotkey_string);
} else if !is_pressed
&& matches!(&stage, Stage::Recording(id) if id == &binding_id)
{
stop(&app, &mut stage, &binding_id, &hotkey_string);
}
} else if is_pressed {
match &stage {
Stage::Idle => {
start(&app, &mut stage, &binding_id, &hotkey_string);
}
Stage::Recording(id) if id == &binding_id => {
stop(&app, &mut stage, &binding_id, &hotkey_string);
}
_ => {
debug!("Ignoring press for '{binding_id}': pipeline busy")
}
}
}
}
Command::Cancel {
recording_was_active,
} => {
// Don't reset during processing — wait for the pipeline to finish.
if !matches!(stage, Stage::Processing)
&& (recording_was_active || matches!(stage, Stage::Recording(_)))
{
stage = Stage::Idle;
}
}
Command::ProcessingFinished => {
stage = Stage::Idle;
}
}
}
debug!("Transcription coordinator exited");
}));
if let Err(e) = result {
error!("Transcription coordinator panicked: {e:?}");
}
});
Self { tx }
}
/// Send a keyboard/signal input event for a transcribe binding.
/// For signal-based toggles, use `is_pressed: true` and `push_to_talk: false`.
pub fn send_input(
&self,
binding_id: &str,
hotkey_string: &str,
is_pressed: bool,
push_to_talk: bool,
) {
if self
.tx
.send(Command::Input {
binding_id: binding_id.to_string(),
hotkey_string: hotkey_string.to_string(),
is_pressed,
push_to_talk,
})
.is_err()
{
warn!("Transcription coordinator channel closed");
}
}
pub fn notify_cancel(&self, recording_was_active: bool) {
if self
.tx
.send(Command::Cancel {
recording_was_active,
})
.is_err()
{
warn!("Transcription coordinator channel closed");
}
}
pub fn notify_processing_finished(&self) {
if self.tx.send(Command::ProcessingFinished).is_err() {
warn!("Transcription coordinator channel closed");
}
}
}
fn start(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) {
let Some(action) = ACTION_MAP.get(binding_id) else {
warn!("No action in ACTION_MAP for '{binding_id}'");
return;
};
action.start(app, binding_id, hotkey_string);
if app
.try_state::<Arc<AudioRecordingManager>>()
.map_or(false, |a| a.is_recording())
{
*stage = Stage::Recording(binding_id.to_string());
} else {
debug!("Start for '{binding_id}' did not begin recording; staying idle");
}
}
fn stop(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) {
let Some(action) = ACTION_MAP.get(binding_id) else {
warn!("No action in ACTION_MAP for '{binding_id}'");
return;
};
action.stop(app, binding_id, hotkey_string);
*stage = Stage::Processing;
}

View file

@ -1,8 +1,8 @@
use crate::managers::audio::AudioRecordingManager;
use crate::managers::transcription::TranscriptionManager;
use crate::shortcut;
use crate::ManagedToggleState;
use log::{info, warn};
use crate::TranscriptionCoordinator;
use log::info;
use std::sync::Arc;
use tauri::{AppHandle, Manager};
@ -20,17 +20,9 @@ pub fn cancel_current_operation(app: &AppHandle) {
// Unregister the cancel shortcut asynchronously
shortcut::unregister_cancel_shortcut(app);
// First, reset all shortcut toggle states.
// This is critical for non-push-to-talk mode where shortcuts toggle on/off
let toggle_state_manager = app.state::<ManagedToggleState>();
if let Ok(mut states) = toggle_state_manager.lock() {
states.active_toggles.values_mut().for_each(|v| *v = false);
} else {
warn!("Failed to lock toggle state manager during cancellation");
}
// Cancel any ongoing recording
let audio_manager = app.state::<Arc<AudioRecordingManager>>();
let recording_was_active = audio_manager.is_recording();
audio_manager.cancel_recording();
// Update tray icon and hide overlay
@ -41,6 +33,11 @@ pub fn cancel_current_operation(app: &AppHandle) {
let tm = app.state::<Arc<TranscriptionManager>>();
tm.maybe_unload_immediately("cancellation");
// Notify coordinator so it can keep lifecycle state coherent.
if let Some(coordinator) = app.try_state::<TranscriptionCoordinator>() {
coordinator.notify_cancel(recording_was_active);
}
info!("Operation cancellation completed - returned to idle state");
}