diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs index a951d978..77a5ff13 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_provider.rs @@ -6,6 +6,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; #[cfg(target_os = "windows")] use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(target_os = "windows")] use std::sync::Arc; @@ -28,6 +29,7 @@ pub struct FoundryLocalWhisperAsr { model_alias: String, language_hint: Option, buffer: Mutex>, + cancel_generation: AtomicU64, } impl FoundryLocalWhisperAsr { @@ -42,6 +44,7 @@ impl FoundryLocalWhisperAsr { model_alias, language_hint: normalize_language_hint(language_hint), buffer: Mutex::new(Vec::new()), + cancel_generation: AtomicU64::new(0), } } @@ -51,6 +54,7 @@ impl FoundryLocalWhisperAsr { model_alias, language_hint: normalize_language_hint(language_hint), buffer: Mutex::new(Vec::new()), + cancel_generation: AtomicU64::new(0), } } @@ -63,6 +67,7 @@ impl FoundryLocalWhisperAsr { } pub async fn transcribe(&self, audio_timeout: std::time::Duration) -> Result { + let cancel_generation = self.cancel_generation.load(Ordering::SeqCst); let pcm = self.buffer.lock().clone(); if pcm.is_empty() { return Ok(RawTranscript { @@ -72,6 +77,9 @@ impl FoundryLocalWhisperAsr { } let result = self.transcribe_inner(&pcm, audio_timeout).await; + if self.cancel_generation.load(Ordering::SeqCst) != cancel_generation { + anyhow::bail!("Foundry Local Whisper transcription cancelled"); + } if result.is_ok() { self.buffer.lock().clear(); } @@ -121,6 +129,7 @@ impl FoundryLocalWhisperAsr { } pub fn cancel(&self) { + self.cancel_generation.fetch_add(1, Ordering::SeqCst); self.buffer.lock().clear(); } } @@ -298,6 +307,12 @@ mod tests { provider.cancel(); assert!(provider.buffer.lock().is_empty()); + assert_eq!( + provider + .cancel_generation + .load(std::sync::atomic::Ordering::SeqCst), + 1 + ); assert_eq!(provider.model_alias(), "whisper-small"); assert_eq!(provider.language_hint(), Some("zh")); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 92b42676..7e84565b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2522,6 +2522,15 @@ async fn end_session(inner: &Arc) -> Result<(), String> { r } Err(e) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] Foundry Local Whisper transcribe cancelled — discarding transcript" + ); + schedule_foundry_local_asr_release(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); emit_capsule( inner, @@ -3179,6 +3188,11 @@ fn foundry_local_asr_release_keep_secs(inner: &Arc) -> u32 { inner.prefs.get().foundry_local_asr_keep_loaded_secs } +#[cfg(target_os = "windows")] +fn foundry_release_session_is_current(inner: &Arc, session_id: u64) -> bool { + inner.state.lock().session_id == session_id +} + #[cfg(target_os = "windows")] fn schedule_foundry_local_asr_release(inner: &Arc, session_id: u64) { let keep_secs = foundry_local_asr_release_keep_secs(inner); @@ -3187,9 +3201,9 @@ fn schedule_foundry_local_asr_release(inner: &Arc, session_id: u64) { tauri::async_runtime::spawn(async move { if keep_secs > 0 { tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; - if inner.state.lock().session_id != session_id { - return; - } + } + if !foundry_release_session_is_current(&inner, session_id) { + return; } if let Err(error) = runtime.release_now().await { log::warn!("[foundry-asr] scheduled release failed: {error:#}"); @@ -4064,6 +4078,26 @@ mod tests { assert_eq!(foundry_local_asr_release_keep_secs(&coordinator.inner), 7); } + #[cfg(target_os = "windows")] + #[test] + fn foundry_release_guard_rejects_stale_session() { + let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); + let coordinator = Coordinator::new_with_foundry_runtime(runtime); + let old_session_id = coordinator.inner.state.lock().session_id; + + assert!(foundry_release_session_is_current( + &coordinator.inner, + old_session_id + )); + + coordinator.inner.state.lock().session_id = old_session_id.wrapping_add(1); + + assert!(!foundry_release_session_is_current( + &coordinator.inner, + old_session_id + )); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!(