From 5da3d517d950569476c9c8c7fd6e9279423806da Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 11 Aug 2026 23:27:22 +0800 Subject: [PATCH 1/2] fix(asr): fallback to CPU after Foundry CUDA failure (#941) --- .../src/asr/local/foundry_provider.rs | 129 +- .../src/asr/local/foundry_runtime.rs | 1220 ++++++++++++++++- .../src-tauri/src/coordinator/dictation.rs | 115 +- .../src-tauri/src/coordinator/qa_session.rs | 12 +- .../src-tauri/src/coordinator/resources.rs | 71 + 5 files changed, 1416 insertions(+), 131 deletions(-) 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 90d39d8aa..ba867f740 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 @@ -7,7 +7,6 @@ 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; #[cfg(target_os = "windows")] @@ -20,6 +19,7 @@ use uuid::Uuid; use crate::asr::wav::encode_wav_16k_mono; use crate::asr::RawTranscript; +use super::foundry_runtime::FoundryFallbackNoticeCallback; #[cfg(target_os = "windows")] use super::foundry_runtime::FoundryLocalRuntime; @@ -81,6 +81,18 @@ impl FoundryLocalWhisperAsr { } pub async fn transcribe(&self, audio_timeout: std::time::Duration) -> Result { + self.transcribe_with_fallback_notice(audio_timeout, Arc::new(|_| {})) + .await + } + + /// 转写当前录音,并在 Foundry 的一次性 GPU→CPU 回退期间同步最小 UI 提示。 + /// + /// 普通转写与历史重新转录继续调用 `transcribe`,因此不会创建新的 UI 协议或提示。 + pub(crate) async fn transcribe_with_fallback_notice( + &self, + audio_timeout: std::time::Duration, + notices: FoundryFallbackNoticeCallback, + ) -> Result { let cancel_generation = self.cancel_generation.load(Ordering::SeqCst); let pcm = self.buffer.lock().clone(); if pcm.is_empty() { @@ -90,7 +102,7 @@ impl FoundryLocalWhisperAsr { }); } - let result = self.transcribe_inner(&pcm, audio_timeout).await; + let result = self.transcribe_inner(&pcm, audio_timeout, notices).await; if self.cancel_generation.load(Ordering::SeqCst) != cancel_generation { anyhow::bail!("Foundry Local Whisper transcription cancelled"); } @@ -104,12 +116,14 @@ impl FoundryLocalWhisperAsr { &self, pcm: &[u8], audio_timeout: std::time::Duration, + notices: FoundryFallbackNoticeCallback, ) -> Result { let duration_ms = pcm_duration_ms(pcm); #[cfg(not(target_os = "windows"))] { let _ = pcm; + let _ = notices; anyhow::bail!( "Foundry Local Whisper is only available on Windows: {}", self.model_alias @@ -122,9 +136,6 @@ impl FoundryLocalWhisperAsr { pcm, Some(FOUNDRY_WHISPER_CHUNK_LIMIT_MS), ); - let deadline = std::time::Instant::now() - .checked_add(audio_timeout) - .ok_or_else(|| anyhow::anyhow!("Foundry Local Whisper timeout is too large"))?; if chunks.len() > 1 { log::info!( "[foundry-asr] splitting {:.2}s audio into {} chunks (limit={}ms)", @@ -134,38 +145,39 @@ impl FoundryLocalWhisperAsr { ); } - let mut texts = Vec::with_capacity(chunks.len()); - for (index, chunk) in chunks.iter().enumerate() { - let wav_file = TempWavFile::create(chunk)?; - let remaining_timeout = - remaining_transcribe_timeout(deadline, std::time::Instant::now()) - .with_context(|| { - format!( - "Foundry Local Whisper total timeout exhausted before chunk {}/{}", - index + 1, - chunks.len() - ) - })?; - let text = self - .runtime - .transcribe_audio_file( - &self.model_alias, - &self.runtime_source, - self.language_hint(), - wav_file.path(), - remaining_timeout, + // 所有临时 WAV 必须在单次 runtime 调用结束后才释放:GPU 失败时,runtime 才能让 + // CPU 重试失败分片并继续后续分片,保持整段录音的一致执行路线。 + let wav_files = chunks + .iter() + .map(|chunk| TempWavFile::create(chunk)) + .collect::>>()?; + let audio_paths = wav_files + .iter() + .map(|wav_file| wav_file.path().to_path_buf()) + .collect::>(); + let outcome = self + .runtime + .transcribe_audio_files( + &self.model_alias, + &self.runtime_source, + self.language_hint(), + &audio_paths, + audio_timeout, + notices, + ) + .await + .with_context(|| { + format!( + "transcribe Foundry Local Whisper recording ({} chunks) with model {}", + chunks.len(), + self.model_alias ) - .await - .with_context(|| { - format!( - "transcribe Foundry Local Whisper chunk {}/{} with model {}", - index + 1, - chunks.len(), - self.model_alias - ) - })?; - texts.push(trim_transcript_text(&text)); - } + })?; + let texts = outcome + .texts + .iter() + .map(|text| trim_transcript_text(text)) + .collect::>(); Ok(RawTranscript { text: crate::asr::whisper::join_transcript_chunks(&texts), @@ -177,7 +189,24 @@ impl FoundryLocalWhisperAsr { pub fn cancel(&self) { self.cancel_generation.fetch_add(1, Ordering::SeqCst); #[cfg(target_os = "windows")] - self.runtime.request_cancel_prepare(); + { + self.runtime.request_cancel_prepare(); + // `end_session` 会 drop 在途 future;若此时已切到临时 CPU,不能等待普通模型 + // 保活计时器才释放。lease 上界将清理限定到当前录音,避免旧取消影响下一段录音。 + if let Some(cancelled_through) = self.runtime.cancellation_cleanup_lease() { + let runtime = Arc::clone(&self.runtime); + tauri::async_runtime::spawn(async move { + if let Err(error) = runtime + .release_temporary_cpu_fallback(cancelled_through) + .await + { + log::warn!( + "[foundry-asr] cancel cleanup for temporary CPU fallback failed: {error:#}" + ); + } + }); + } + } self.buffer.lock().clear(); } } @@ -192,16 +221,6 @@ fn pcm_duration_ms(pcm: &[u8]) -> u64 { crate::asr::pcm::pcm_duration_ms(pcm) } -fn remaining_transcribe_timeout( - deadline: std::time::Instant, - now: std::time::Instant, -) -> Result { - deadline - .checked_duration_since(now) - .filter(|duration| !duration.is_zero()) - .ok_or_else(|| anyhow::anyhow!("Foundry Local Whisper total timeout exhausted")) -} - fn pcm_to_wav(pcm: &[u8]) -> Vec { let samples: Vec = pcm .chunks_exact(2) @@ -358,22 +377,6 @@ mod tests { assert_eq!(chunks[2].len(), 32_000 * 5); } - #[test] - fn foundry_chunk_timeout_uses_remaining_total_budget() { - let started = std::time::Instant::now(); - let deadline = started + std::time::Duration::from_secs(85); - - assert_eq!( - super::remaining_transcribe_timeout( - deadline, - started + std::time::Duration::from_secs(30), - ) - .unwrap(), - std::time::Duration::from_secs(55) - ); - assert!(super::remaining_transcribe_timeout(deadline, deadline).is_err()); - } - #[test] fn foundry_provider_reports_buffer_duration_without_consuming() { #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index c80ed99f3..0ef05a92d 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -1,4 +1,59 @@ #![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] + +use std::sync::Arc; + +/// CPU 回退期间向调用方报告的最小状态。调用方只决定如何展示,不参与模型选择。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FoundryFallbackNotice { + SwitchingToCpu, + DownloadingCpu, +} + +impl FoundryFallbackNotice { + pub(crate) const fn message(self) -> &'static str { + match self { + Self::SwitchingToCpu => "检测到 GPU 识别异常,正在切换 CPU…", + Self::DownloadingCpu => "正在下载 CPU 模型,首次使用可能较慢…", + } + } +} + +pub(crate) type FoundryFallbackNoticeCallback = + Arc; + +/// 一段录音的 Foundry 转写结果。仅供本地 ASR provider 消费,不扩展 IPC 协议。 +#[derive(Debug, Clone, Default)] +pub(crate) struct FoundryTranscriptionOutcome { + pub texts: Vec, + pub used_cpu_fallback: bool, + pub gpu_model_id: Option, + pub cpu_model_id: Option, +} + +/// 单次录音回退临时 CPU 模型的运行时 lease。 +/// +/// 取消清理必须带上该 lease,避免旧录音的异步清理误卸载下一段录音重新加载的同一 CPU variant。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct FoundryTemporaryCpuFallbackLease(u64); + +/// CPU 回退已经尝试但不能完成时的终态标记。 +/// +/// Coordinator 据此跳过面对瞬态网络错误设计的静默重试,避免重新命中同一 CUDA 路径。 +#[derive(Debug, thiserror::Error)] +#[error("Foundry CUDA CPU fallback failed; gpu_error={gpu_error}; cpu_error={cpu_error}")] +pub(crate) struct FoundryCpuFallbackTerminalError { + gpu_error: String, + cpu_error: String, +} + +pub(crate) fn is_terminal_foundry_fallback_error(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some() + }) +} + #[cfg(target_os = "windows")] #[allow(dead_code)] mod imp { @@ -7,12 +62,17 @@ mod imp { atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }; + use std::time::{Duration, Instant}; use anyhow::{Context, Result}; - use foundry_local_sdk::{FoundryLocalConfig, FoundryLocalManager, Model}; + use foundry_local_sdk::{DeviceType, FoundryLocalConfig, FoundryLocalManager, Model}; use parking_lot::Mutex; use tokio::sync::Mutex as AsyncMutex; + use super::{ + FoundryCpuFallbackTerminalError, FoundryFallbackNotice, FoundryFallbackNoticeCallback, + FoundryTemporaryCpuFallbackLease, FoundryTranscriptionOutcome, + }; use crate::asr::local::foundry::{ FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus, MODELS, PROVIDER_ID, @@ -22,11 +82,107 @@ mod imp { type FoundryPrepareProgressCallback = Arc; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum FoundryExecutionDevice { + Cpu, + Gpu, + Other, + } + + impl FoundryExecutionDevice { + fn from_model(model: &Model) -> Self { + match model + .info() + .runtime + .as_ref() + .map(|runtime| &runtime.device_type) + { + Some(DeviceType::CPU) => Self::Cpu, + Some(DeviceType::GPU) => Self::Gpu, + _ => Self::Other, + } + } + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct FoundryVariantDescriptor { + id: String, + version: u64, + device: FoundryExecutionDevice, + } + + impl FoundryVariantDescriptor { + fn new(id: impl Into, version: u64, device: FoundryExecutionDevice) -> Self { + Self { + id: id.into(), + version, + device, + } + } + } + + fn select_cpu_variant_id(variants: &[FoundryVariantDescriptor]) -> Option { + variants + .iter() + .filter(|variant| variant.device == FoundryExecutionDevice::Cpu) + .max_by(|left, right| { + left.version + .cmp(&right.version) + .then_with(|| left.id.cmp(&right.id)) + }) + .map(|variant| variant.id.clone()) + } + + fn is_cuda_cudnn_failure(error: &str) -> bool { + let error = error.to_ascii_lowercase(); + error.contains("cudnn_fe") + || error.contains("cudnn_backend_api_failed") + || error.contains("failed to initialize cudnn frontend") + || error.contains("cudnn_engines_precompiled64_9.dll") + } + + fn may_reuse_loaded_model( + loaded_alias: &str, + requested_alias: &str, + temporary_cpu_fallback: bool, + ) -> bool { + loaded_alias == requested_alias && !temporary_cpu_fallback + } + + fn should_release_temporary_cpu_fallback( + loaded_lease: Option, + cancelled_through: FoundryTemporaryCpuFallbackLease, + ) -> bool { + loaded_lease.is_some_and(|lease| lease <= cancelled_through) + } + #[derive(Clone)] struct LoadedModel { alias: String, model_id: String, model: Arc, + device: FoundryExecutionDevice, + temporary_cpu_fallback_lease: Option, + } + + impl LoadedModel { + fn new( + alias: impl Into, + model: Arc, + temporary_cpu_fallback_lease: Option, + ) -> Self { + Self { + alias: alias.into(), + model_id: model.id().to_string(), + device: FoundryExecutionDevice::from_model(&model), + model, + temporary_cpu_fallback_lease, + } + } + + fn is_temporary_cpu_fallback(&self) -> bool { + self.temporary_cpu_fallback_lease.is_some() + } } #[derive(Default)] @@ -35,9 +191,363 @@ mod imp { loaded: Option, } + #[derive(Debug, Clone)] + struct FoundryCpuSwitch { + model_id: String, + cache_hit: bool, + } + + /// 将「按分片转写、识别 CUDA 错误、一次 CPU 回退、清理临时模型」收敛到一个 + /// 可替换的执行接口中。生产路径使用 SDK adapter;测试路径使用脚本化 fake,完全不需 GPU。 + #[allow(async_fn_in_trait)] + trait FoundryExecutionAdapter { + fn alias(&self) -> &str; + fn execution_device(&self) -> FoundryExecutionDevice; + fn model_id(&self) -> &str; + async fn transcribe(&mut self, audio_path: &Path, timeout: Duration) -> Result; + async fn switch_to_cpu( + &mut self, + notices: &FoundryFallbackNoticeCallback, + ) -> Result; + async fn finish(&mut self) -> Result<()>; + } + + async fn transcribe_recording_with_adapter( + adapter: &mut A, + audio_paths: &[PathBuf], + audio_timeout: Duration, + notices: &FoundryFallbackNoticeCallback, + ) -> Result { + let result = async { + let mut outcome = FoundryTranscriptionOutcome { + texts: Vec::with_capacity(audio_paths.len()), + used_cpu_fallback: false, + gpu_model_id: (adapter.execution_device() == FoundryExecutionDevice::Gpu) + .then(|| adapter.model_id().to_string()), + cpu_model_id: None, + }; + let mut fallback_gpu_error = None; + let mut fallback_started_at = None; + let mut deadline = Instant::now() + .checked_add(audio_timeout) + .context("Foundry Local Whisper transcription timeout is too large")?; + + for (index, audio_path) in audio_paths.iter().enumerate() { + let timeout = remaining_transcription_timeout(deadline, Instant::now()) + .with_context(|| { + format!( + "Foundry Local Whisper total timeout exhausted before chunk {}/{}", + index + 1, + audio_paths.len() + ) + })?; + match adapter.transcribe(audio_path, timeout).await { + Ok(text) => outcome.texts.push(text), + Err(error) + if !outcome.used_cpu_fallback + && adapter.execution_device() == FoundryExecutionDevice::Gpu + && is_cuda_cudnn_failure(&format!("{error:#}")) => + { + let gpu_error = format!("{error:#}"); + let fallback_started = Instant::now(); + let alias = adapter.alias().to_string(); + let gpu_model_id = adapter.model_id().to_string(); + log::warn!( + "[foundry-asr] event=cuda_fallback_detected alias={} gpu_model={} chunk={}/{} error_category=cudnn_cuda", + alias, + gpu_model_id, + index + 1, + audio_paths.len() + ); + notices(FoundryFallbackNotice::SwitchingToCpu); + let cpu = match adapter.switch_to_cpu(notices).await { + Ok(cpu) => cpu, + Err(cpu_error) => { + log::error!( + "[foundry-asr] event=cuda_fallback_failed alias={} gpu_model={} fallback_stage=cpu_prepare duration_ms={} error_category=cpu_prepare", + alias, + gpu_model_id, + fallback_started.elapsed().as_millis() + ); + return Err(anyhow::Error::new(FoundryCpuFallbackTerminalError { + gpu_error: gpu_error.clone(), + cpu_error: format!("{cpu_error:#}"), + })); + } + }; + log::warn!( + "[foundry-asr] event=cuda_fallback_cpu_ready alias={} gpu_model={} cpu_model={} cpu_cache_hit={} duration_ms={}", + alias, + gpu_model_id, + cpu.model_id, + cpu.cache_hit, + fallback_started.elapsed().as_millis() + ); + outcome.used_cpu_fallback = true; + outcome.cpu_model_id = Some(cpu.model_id); + fallback_gpu_error = Some(gpu_error); + fallback_started_at = Some(fallback_started); + // CPU 是一次恢复路径:首次下载/加载不耗尽原 GPU 的推理预算, + // 因此给尚未完成的分片一段新的同规格推理窗口。 + deadline = Instant::now() + .checked_add(audio_timeout) + .context("Foundry CPU fallback timeout is too large")?; + let retry_timeout = + remaining_transcription_timeout(deadline, Instant::now())?; + let text = match adapter.transcribe(audio_path, retry_timeout).await { + Ok(text) => text, + Err(cpu_error) => { + log::error!( + "[foundry-asr] event=cuda_fallback_failed alias={} gpu_model={} cpu_model={} fallback_stage=cpu_inference duration_ms={} error_category=cpu_inference", + alias, + gpu_model_id, + outcome.cpu_model_id.as_deref().unwrap_or("unknown"), + fallback_started.elapsed().as_millis() + ); + return Err(anyhow::Error::new(FoundryCpuFallbackTerminalError { + gpu_error: fallback_gpu_error + .clone() + .unwrap_or_else(|| "unknown CUDA error".to_string()), + cpu_error: format!("{cpu_error:#}"), + })); + } + }; + outcome.texts.push(text); + } + Err(error) if outcome.used_cpu_fallback => { + log::error!( + "[foundry-asr] event=cuda_fallback_failed alias={} gpu_model={} cpu_model={} fallback_stage=cpu_inference duration_ms={} error_category=cpu_inference", + adapter.alias(), + outcome.gpu_model_id.as_deref().unwrap_or("unknown"), + outcome.cpu_model_id.as_deref().unwrap_or("unknown"), + fallback_started_at + .map(|started| started.elapsed().as_millis()) + .unwrap_or_default() + ); + return Err(anyhow::Error::new(FoundryCpuFallbackTerminalError { + gpu_error: fallback_gpu_error + .clone() + .unwrap_or_else(|| "unknown CUDA error".to_string()), + cpu_error: format!("{error:#}"), + })); + } + Err(error) => return Err(error), + } + } + if outcome.used_cpu_fallback { + log::info!( + "[foundry-asr] event=cuda_fallback_completed alias={} gpu_model={} cpu_model={} duration_ms={}", + adapter.alias(), + outcome.gpu_model_id.as_deref().unwrap_or("unknown"), + outcome.cpu_model_id.as_deref().unwrap_or("unknown"), + fallback_started_at + .map(|started| started.elapsed().as_millis()) + .unwrap_or_default() + ); + } + Ok(outcome) + } + .await; + + // 临时 CPU 模型无论结果如何都应释放;清理失败不能覆盖已拿到的转写文本, + // 但会保留 runtime state,令下一次默认准备路径继续负责回收它。 + if let Err(error) = adapter.finish().await { + log::warn!("[foundry-asr] release temporary CPU fallback model failed: {error:#}"); + } + result + } + + fn remaining_transcription_timeout(deadline: Instant, now: Instant) -> Result { + deadline + .checked_duration_since(now) + .filter(|duration| !duration.is_zero()) + .ok_or_else(|| anyhow::anyhow!("Foundry Local Whisper total timeout exhausted")) + } + + struct FoundrySdkExecution<'a> { + runtime: &'a FoundryLocalRuntime, + manager: &'static FoundryLocalManager, + alias: &'a str, + language_hint: Option, + loaded: LoadedModel, + using_temporary_cpu_fallback: bool, + } + + impl FoundrySdkExecution<'_> { + async fn restore_after_failed_cpu_switch( + &self, + previous: &LoadedModel, + error: anyhow::Error, + ) -> Result { + if let Err(restore_error) = self + .runtime + .restore_loaded_model(self.manager, previous) + .await + { + return Err(error.context(format!( + "CPU fallback also failed to restore GPU model {}: {restore_error:#}", + previous.model_id + ))); + } + Err(error) + } + } + + impl FoundryExecutionAdapter for FoundrySdkExecution<'_> { + fn alias(&self) -> &str { + self.alias + } + + fn execution_device(&self) -> FoundryExecutionDevice { + self.loaded.device + } + + fn model_id(&self) -> &str { + &self.loaded.model_id + } + + async fn transcribe(&mut self, audio_path: &Path, timeout: Duration) -> Result { + let mut client = self.loaded.model.create_audio_client(); + if let Some(language_hint) = self.language_hint.as_deref() { + client = client.language(language_hint); + } + let model_id = self.loaded.model_id.clone(); + let result = tokio::time::timeout(timeout, client.transcribe(audio_path)) + .await + .with_context(|| { + format!( + "transcribe audio with Foundry model {model_id} timed out after {} seconds", + timeout.as_secs() + ) + })? + .with_context(|| format!("transcribe audio with Foundry model {model_id}"))?; + Ok(result.text) + } + + async fn switch_to_cpu( + &mut self, + notices: &FoundryFallbackNoticeCallback, + ) -> Result { + // 在任何 await 之前分配 lease:取消方可以用当时的 lease 上界安全清理尚未 + // 完成加载的临时模型,同时不会触及随后新录音分配的更高 lease。 + let lease = self.runtime.next_temporary_cpu_fallback_lease(); + self.runtime.check_prepare_cancelled()?; + let cpu_model = self + .runtime + .cpu_variant_model(self.manager, self.alias) + .await?; + self.runtime.check_prepare_cancelled()?; + let cpu_model_id = cpu_model.id().to_string(); + let cached = cpu_model + .is_cached() + .await + .with_context(|| format!("check Foundry CPU model cache {cpu_model_id}"))?; + log::info!( + "[foundry-asr] event=cpu_variant_selected alias={} gpu_model={} cpu_model={} cpu_cache_hit={}", + self.alias, + self.loaded.model_id, + cpu_model_id, + cached + ); + if !cached { + notices(FoundryFallbackNotice::DownloadingCpu); + log::info!( + "[foundry-asr] event=cpu_download_started alias={} cpu_model={}", + self.alias, + cpu_model_id + ); + cpu_model + .download_builder() + .cancel(Arc::clone(&self.runtime.cancel_prepare)) + .run() + .await + .with_context(|| format!("download Foundry CPU model {cpu_model_id}"))?; + log::info!( + "[foundry-asr] event=cpu_download_completed alias={} cpu_model={}", + self.alias, + cpu_model_id + ); + } + self.runtime.check_prepare_cancelled()?; + + let previous = self.loaded.clone(); + if let Err(error) = FoundryLocalRuntime::unload_model(&previous).await { + return Err(error.context(format!( + "unload GPU model {} before CPU fallback", + previous.model_id + ))); + } + self.runtime.clear_loaded_if_model_id(&previous.model_id); + + // 先把带 lease 的临时模型记入 runtime state,再等待 load。若外层因取消 drop + // 当前 future,取消清理任务将在 lifecycle 锁释放后看到这份 state 并卸载它。 + let loaded = LoadedModel::new(self.alias, Arc::clone(&cpu_model), Some(lease)); + *self.runtime.state.lock() = RuntimeState { + manager: Some(self.manager), + loaded: Some(loaded.clone()), + }; + + log::info!( + "[foundry-asr] event=cpu_load_started alias={} cpu_model={}", + self.alias, + cpu_model_id + ); + if let Err(error) = cpu_model + .load() + .await + .with_context(|| format!("load Foundry CPU model {cpu_model_id}")) + { + self.runtime.clear_loaded_if_model_id(&loaded.model_id); + if let Err(cleanup_error) = cpu_model.unload().await { + log::warn!( + "[foundry-asr] event=cpu_load_failure_cleanup_failed alias={} cpu_model={}: {cleanup_error:#}", + self.alias, + cpu_model_id + ); + } + return self.restore_after_failed_cpu_switch(&previous, error).await; + } + if let Err(error) = self.runtime.check_prepare_cancelled() { + if let Err(cleanup_error) = cpu_model.unload().await { + log::warn!( + "[foundry-asr] event=cpu_cancel_cleanup_failed alias={} cpu_model={}: {cleanup_error:#}", + self.alias, + cpu_model_id + ); + } + self.runtime.clear_loaded_if_model_id(&loaded.model_id); + return Err(error); + } + + self.loaded = loaded; + self.using_temporary_cpu_fallback = true; + log::warn!( + "[foundry-asr] event=cpu_load_completed alias={} gpu_model={} cpu_model={} cpu_cache_hit={}", + self.alias, + previous.model_id, + self.loaded.model_id, + cached + ); + Ok(FoundryCpuSwitch { + model_id: cpu_model_id, + cache_hit: cached, + }) + } + + async fn finish(&mut self) -> Result<()> { + if self.using_temporary_cpu_fallback { + FoundryLocalRuntime::unload_model(&self.loaded).await?; + self.runtime.clear_loaded_if_model_id(&self.loaded.model_id); + self.using_temporary_cpu_fallback = false; + } + Ok(()) + } + } + pub struct FoundryLocalRuntime { lifecycle: AsyncMutex<()>, - cancel_prepare: AtomicBool, + cancel_prepare: Arc, + temporary_cpu_fallback_sequence: AtomicU64, state: Mutex, } @@ -51,7 +561,8 @@ mod imp { pub fn new() -> Self { Self { lifecycle: AsyncMutex::new(()), - cancel_prepare: AtomicBool::new(false), + cancel_prepare: Arc::new(AtomicBool::new(false)), + temporary_cpu_fallback_sequence: AtomicU64::new(0), state: Mutex::new(RuntimeState::default()), } } @@ -167,27 +678,45 @@ mod imp { audio_path: &Path, audio_timeout: std::time::Duration, ) -> Result { + let outcome = self + .transcribe_audio_files( + alias, + runtime_source, + language_hint, + &[audio_path.to_path_buf()], + audio_timeout, + Arc::new(|_| {}), + ) + .await?; + Ok(outcome.texts.into_iter().next().unwrap_or_default()) + } + + pub async fn transcribe_audio_files( + &self, + alias: &str, + runtime_source: &str, + language_hint: Option<&str>, + audio_paths: &[PathBuf], + audio_timeout: Duration, + notices: FoundryFallbackNoticeCallback, + ) -> Result { let _lifecycle = self.lifecycle.lock().await; self.cancel_prepare.store(false, Ordering::SeqCst); let runtime_source = foundry_native::normalize_runtime_source(runtime_source); - let model = self + let loaded = self .ensure_loaded_locked(alias, runtime_source, Arc::new(|_| {})) - .await? - .model; - let mut client = model.create_audio_client(); - if let Some(language_hint) = normalized_language_hint(language_hint) { - client = client.language(language_hint); - } - let result = tokio::time::timeout(audio_timeout, client.transcribe(audio_path)) + .await?; + let manager = self.manager()?; + let mut execution = FoundrySdkExecution { + runtime: self, + manager, + alias, + language_hint: normalized_language_hint(language_hint), + loaded, + using_temporary_cpu_fallback: false, + }; + transcribe_recording_with_adapter(&mut execution, audio_paths, audio_timeout, ¬ices) .await - .with_context(|| { - format!( - "transcribe audio with Foundry model {alias} timed out after {} seconds", - audio_timeout.as_secs() - ) - })? - .with_context(|| format!("transcribe audio with Foundry model {alias}"))?; - Ok(result.text) } pub async fn release_now(&self) -> Result<()> { @@ -195,6 +724,40 @@ mod imp { self.release_now_locked().await } + /// 返回当前取消可清理到的 CPU 回退 lease 上界。 + /// + /// 该值覆盖已经开始但尚未完成加载的回退;后续录音一定分配更高 lease,因此旧取消 + /// 不会影响下一段录音。 + pub fn cancellation_cleanup_lease(&self) -> Option { + let sequence = self.temporary_cpu_fallback_sequence.load(Ordering::SeqCst); + (sequence != 0).then_some(FoundryTemporaryCpuFallbackLease(sequence)) + } + + /// 取消当前录音时仅清理不晚于 `cancelled_through` 的临时 CPU 模型;正常 alias 模型仍遵循 + /// 用户已有的保活设置。该方法会等待在途下载/加载/推理释放 lifecycle 锁。 + pub async fn release_temporary_cpu_fallback( + &self, + cancelled_through: FoundryTemporaryCpuFallbackLease, + ) -> Result<()> { + let _lifecycle = self.lifecycle.lock().await; + let temporary_cpu = self.loaded_model_snapshot().filter(|loaded| { + should_release_temporary_cpu_fallback( + loaded.temporary_cpu_fallback_lease, + cancelled_through, + ) + }); + if let Some(loaded) = temporary_cpu { + Self::unload_model(&loaded).await?; + self.clear_loaded_if_model_id(&loaded.model_id); + log::info!( + "[foundry-asr] event=cpu_fallback_released alias={} cpu_model={}", + loaded.alias, + loaded.model_id + ); + } + Ok(()) + } + pub fn storage_configuration_locked(&self) -> bool { self.state.lock().manager.is_some() } @@ -250,7 +813,7 @@ mod imp { return Ok(loaded); } - let previous_loaded = self.loaded_for_different_alias(alias); + let previous_loaded = self.loaded_for_replacement(alias); self.check_prepare_cancelled()?; foundry_native::ensure_runtime(runtime_source, { @@ -359,11 +922,7 @@ mod imp { model_label.clone(), 100.0, )); - let loaded = LoadedModel { - alias: alias.to_string(), - model_id, - model, - }; + let loaded = LoadedModel::new(alias, model, None); *self.state.lock() = RuntimeState { manager: Some(manager), loaded: Some(loaded.clone()), @@ -417,11 +976,7 @@ mod imp { 100.0, )); - let loaded = LoadedModel { - alias: alias.to_string(), - model_id, - model, - }; + let loaded = LoadedModel::new(alias, model, None); *self.state.lock() = RuntimeState { manager: Some(manager), loaded: Some(loaded.clone()), @@ -478,12 +1033,44 @@ mod imp { Err(error) } + async fn cpu_variant_model( + &self, + manager: &'static FoundryLocalManager, + alias: &str, + ) -> Result> { + let catalog = manager.catalog(); + let model = catalog + .get_model(alias) + .await + .with_context(|| format!("get Foundry model variants for {alias}"))?; + let variants = model + .variants() + .into_iter() + .map(|variant| { + FoundryVariantDescriptor::new( + variant.id(), + variant.info().version, + FoundryExecutionDevice::from_model(&variant), + ) + }) + .collect::>(); + let cpu_variant_id = select_cpu_variant_id(&variants).with_context(|| { + format!("Foundry model {alias} has no CPU variant for CUDA fallback") + })?; + catalog + .get_model_variant(&cpu_variant_id) + .await + .with_context(|| format!("get Foundry CPU model variant {cpu_variant_id}")) + } + fn cached_loaded_model(&self, alias: &str) -> Option { self.state .lock() .loaded .as_ref() - .filter(|loaded| loaded.alias == alias) + .filter(|loaded| { + may_reuse_loaded_model(&loaded.alias, alias, loaded.is_temporary_cpu_fallback()) + }) .cloned() } @@ -523,12 +1110,18 @@ mod imp { self.state.lock().loaded.clone() } - fn loaded_for_different_alias(&self, alias: &str) -> Option { + fn loaded_for_replacement(&self, alias: &str) -> Option { self.state .lock() .loaded .as_ref() - .filter(|loaded| loaded.alias != alias) + .filter(|loaded| { + !may_reuse_loaded_model( + &loaded.alias, + alias, + loaded.is_temporary_cpu_fallback(), + ) + }) .cloned() } @@ -558,6 +1151,14 @@ mod imp { } Ok(()) } + + fn next_temporary_cpu_fallback_lease(&self) -> FoundryTemporaryCpuFallbackLease { + FoundryTemporaryCpuFallbackLease( + self.temporary_cpu_fallback_sequence + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1), + ) + } } fn model_display_label(alias: &str) -> String { @@ -594,10 +1195,533 @@ mod imp { #[cfg(test)] mod lifecycle_tests { use super::{ - foundry_native_dir_candidates, normalized_language_hint, select_foundry_native_dir, - FoundryLocalRuntime, + foundry_native_dir_candidates, is_cuda_cudnn_failure, may_reuse_loaded_model, + normalized_language_hint, select_cpu_variant_id, select_foundry_native_dir, + should_release_temporary_cpu_fallback, transcribe_recording_with_adapter, + FoundryCpuSwitch, FoundryExecutionAdapter, FoundryExecutionDevice, + FoundryFallbackNotice, FoundryFallbackNoticeCallback, FoundryLocalRuntime, + FoundryVariantDescriptor, + }; + use anyhow::Result; + use std::{ + collections::VecDeque, + fs, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, }; - use std::fs; + + enum ScriptedTranscription { + Text(&'static str), + Error(&'static str), + } + + enum ScriptedCpuSwitch { + Success { + model_id: &'static str, + download_required: bool, + }, + Error(&'static str), + } + + struct ScriptedExecution { + device: FoundryExecutionDevice, + model_id: String, + transcriptions: VecDeque, + cpu_switch: Option, + transcribe_devices: Vec, + transcribe_timeouts: Vec, + cpu_switch_delay: Duration, + switch_count: usize, + finish_count: usize, + released_temporary_cpu: bool, + } + + impl ScriptedExecution { + fn gpu( + transcriptions: impl IntoIterator, + cpu_switch: ScriptedCpuSwitch, + ) -> Self { + Self { + device: FoundryExecutionDevice::Gpu, + model_id: "whisper-medium-gpu:4".to_string(), + transcriptions: transcriptions.into_iter().collect(), + cpu_switch: Some(cpu_switch), + transcribe_devices: Vec::new(), + transcribe_timeouts: Vec::new(), + cpu_switch_delay: Duration::ZERO, + switch_count: 0, + finish_count: 0, + released_temporary_cpu: false, + } + } + + fn with_cpu_switch_delay(mut self, delay: Duration) -> Self { + self.cpu_switch_delay = delay; + self + } + } + + impl FoundryExecutionAdapter for ScriptedExecution { + fn alias(&self) -> &str { + "whisper-medium" + } + + fn execution_device(&self) -> FoundryExecutionDevice { + self.device + } + + fn model_id(&self) -> &str { + &self.model_id + } + + async fn transcribe( + &mut self, + _audio_path: &Path, + timeout: Duration, + ) -> Result { + self.transcribe_devices.push(self.device); + self.transcribe_timeouts.push(timeout); + match self + .transcriptions + .pop_front() + .expect("test script must provide every transcription result") + { + ScriptedTranscription::Text(text) => Ok(text.to_string()), + ScriptedTranscription::Error(error) => anyhow::bail!("{error}"), + } + } + + async fn switch_to_cpu( + &mut self, + notices: &FoundryFallbackNoticeCallback, + ) -> Result { + self.switch_count += 1; + if !self.cpu_switch_delay.is_zero() { + tokio::time::sleep(self.cpu_switch_delay).await; + } + match self + .cpu_switch + .take() + .expect("CPU switch may only be attempted once") + { + ScriptedCpuSwitch::Success { + model_id, + download_required, + } => { + if download_required { + notices(FoundryFallbackNotice::DownloadingCpu); + } + self.device = FoundryExecutionDevice::Cpu; + self.model_id = model_id.to_string(); + Ok(FoundryCpuSwitch { + model_id: model_id.to_string(), + cache_hit: !download_required, + }) + } + ScriptedCpuSwitch::Error(error) => anyhow::bail!("{error}"), + } + } + + async fn finish(&mut self) -> Result<()> { + self.finish_count += 1; + self.released_temporary_cpu = self.device == FoundryExecutionDevice::Cpu; + Ok(()) + } + } + + fn audio_paths(count: usize) -> Vec { + (1..=count) + .map(|index| PathBuf::from(format!("chunk-{index}.wav"))) + .collect() + } + + fn notices() -> ( + FoundryFallbackNoticeCallback, + Arc>>, + ) { + let received = Arc::new(std::sync::Mutex::new(Vec::new())); + let callback_received = Arc::clone(&received); + let callback: FoundryFallbackNoticeCallback = Arc::new(move |notice| { + callback_received.lock().unwrap().push(notice); + }); + (callback, received) + } + + #[tokio::test] + async fn cuda_failure_retries_the_failed_chunk_once_on_cpu_and_keeps_cpu_for_later_chunks() + { + let mut execution = ScriptedExecution::gpu( + [ + ScriptedTranscription::Text("first"), + ScriptedTranscription::Error("CUDNN_FE failure 11: CUDNN_BACKEND_API_FAILED"), + ScriptedTranscription::Text("second"), + ScriptedTranscription::Text("third"), + ], + ScriptedCpuSwitch::Success { + model_id: "whisper-medium-cpu:4", + download_required: true, + }, + ); + let (callback, received) = notices(); + + let outcome = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(3), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap(); + + assert_eq!(outcome.texts, ["first", "second", "third"]); + assert!(outcome.used_cpu_fallback); + assert_eq!( + outcome.gpu_model_id.as_deref(), + Some("whisper-medium-gpu:4") + ); + assert_eq!( + outcome.cpu_model_id.as_deref(), + Some("whisper-medium-cpu:4") + ); + assert_eq!(execution.switch_count, 1); + assert_eq!( + execution.transcribe_devices, + [ + FoundryExecutionDevice::Gpu, + FoundryExecutionDevice::Gpu, + FoundryExecutionDevice::Cpu, + FoundryExecutionDevice::Cpu, + ] + ); + assert_eq!(execution.finish_count, 1); + assert!(execution.released_temporary_cpu); + assert_eq!( + *received.lock().unwrap(), + [ + FoundryFallbackNotice::SwitchingToCpu, + FoundryFallbackNotice::DownloadingCpu, + ] + ); + } + + #[tokio::test] + async fn cpu_retry_receives_a_fresh_inference_budget_after_model_preparation() { + let mut execution = ScriptedExecution::gpu( + [ + ScriptedTranscription::Error("CUDNN_BACKEND_API_FAILED during GPU inference"), + ScriptedTranscription::Text("recovered"), + ], + ScriptedCpuSwitch::Success { + model_id: "whisper-medium-cpu:4", + download_required: true, + }, + ) + .with_cpu_switch_delay(Duration::from_millis(100)); + let (callback, _) = notices(); + + let outcome = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_millis(150), + &callback, + ) + .await + .unwrap(); + + assert_eq!(outcome.texts, ["recovered"]); + assert_eq!(execution.transcribe_timeouts.len(), 2); + assert!( + execution.transcribe_timeouts[1] >= Duration::from_millis(120), + "CPU retry should retain a fresh dynamic inference budget after preparation" + ); + } + + #[tokio::test] + async fn non_cuda_failure_keeps_the_existing_error_path_without_cpu_fallback() { + let mut execution = ScriptedExecution::gpu( + [ScriptedTranscription::Error("network request timed out")], + ScriptedCpuSwitch::Success { + model_id: "whisper-medium-cpu:4", + download_required: false, + }, + ); + let (callback, received) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("network request timed out")); + assert!(!super::super::is_terminal_foundry_fallback_error(&error)); + assert_eq!(execution.switch_count, 0); + assert_eq!(execution.finish_count, 1); + assert!(!execution.released_temporary_cpu); + assert!(received.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn cuda_signature_on_a_non_gpu_variant_does_not_trigger_cpu_fallback() { + let mut execution = ScriptedExecution::gpu( + [ScriptedTranscription::Error( + "CUDNN_FE failure 11: CUDNN_BACKEND_API_FAILED", + )], + ScriptedCpuSwitch::Success { + model_id: "whisper-medium-cpu:4", + download_required: false, + }, + ); + execution.device = FoundryExecutionDevice::Cpu; + execution.model_id = "whisper-medium-cpu:4".to_string(); + let (callback, _) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(!super::super::is_terminal_foundry_fallback_error(&error)); + assert_eq!(execution.switch_count, 0); + assert_eq!(execution.finish_count, 1); + } + + #[tokio::test] + async fn unavailable_cpu_variant_is_a_terminal_fallback_error_without_a_second_gpu_attempt() + { + let mut execution = ScriptedExecution::gpu( + [ScriptedTranscription::Error( + "Failed to initialize CUDNN Frontend", + )], + ScriptedCpuSwitch::Error("Foundry model whisper-medium has no CPU variant"), + ); + let (callback, _) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(super::super::is_terminal_foundry_fallback_error(&error)); + assert!(error + .to_string() + .contains("Failed to initialize CUDNN Frontend")); + assert!(error.to_string().contains("has no CPU variant")); + assert_eq!(execution.switch_count, 1); + assert_eq!(execution.transcribe_devices, [FoundryExecutionDevice::Gpu]); + assert_eq!(execution.finish_count, 1); + } + + #[tokio::test] + async fn cpu_download_failure_is_terminal_and_does_not_retry_the_gpu_chunk() { + let mut execution = ScriptedExecution::gpu( + [ScriptedTranscription::Error( + "CUDNN_BACKEND_API_FAILED during GPU inference", + )], + ScriptedCpuSwitch::Error("download Foundry CPU model whisper-medium-cpu:4 failed"), + ); + let (callback, _) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(super::super::is_terminal_foundry_fallback_error(&error)); + assert!(error.to_string().contains("download Foundry CPU model")); + assert_eq!(execution.switch_count, 1); + assert_eq!(execution.transcribe_devices, [FoundryExecutionDevice::Gpu]); + assert_eq!(execution.finish_count, 1); + } + + #[tokio::test] + async fn cpu_transcription_failure_is_terminal_and_still_releases_the_cpu_model() { + let mut execution = ScriptedExecution::gpu( + [ + ScriptedTranscription::Error( + "Could not locate cudnn_engines_precompiled64_9.dll", + ), + ScriptedTranscription::Error("CPU model inference failed"), + ], + ScriptedCpuSwitch::Success { + model_id: "whisper-medium-cpu:4", + download_required: false, + }, + ); + let (callback, _) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(super::super::is_terminal_foundry_fallback_error(&error)); + assert!(error.to_string().contains("CPU model inference failed")); + assert_eq!(execution.switch_count, 1); + assert_eq!( + execution.transcribe_devices, + [FoundryExecutionDevice::Gpu, FoundryExecutionDevice::Cpu] + ); + assert_eq!(execution.finish_count, 1); + assert!(execution.released_temporary_cpu); + } + + #[tokio::test] + async fn cpu_download_or_load_cancellation_is_terminal_and_leaves_no_cpu_route() { + for stage in ["CPU download", "CPU load"] { + let mut execution = ScriptedExecution::gpu( + [ScriptedTranscription::Error( + "CUDNN_BACKEND_API_FAILED during GPU inference", + )], + ScriptedCpuSwitch::Error(match stage { + "CPU download" => { + "Foundry Local Whisper prepare cancelled during CPU download" + } + "CPU load" => "Foundry Local Whisper prepare cancelled during CPU load", + _ => unreachable!(), + }), + ); + let (callback, _) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(super::super::is_terminal_foundry_fallback_error(&error)); + assert!(error.to_string().contains(stage)); + assert_eq!(execution.finish_count, 1); + assert!(!execution.released_temporary_cpu); + } + } + + #[tokio::test] + async fn cpu_inference_cancellation_releases_the_temporary_cpu_route_without_text() { + let mut execution = ScriptedExecution::gpu( + [ + ScriptedTranscription::Error("CUDNN_BACKEND_API_FAILED during GPU inference"), + ScriptedTranscription::Error("Foundry Local Whisper transcription cancelled"), + ], + ScriptedCpuSwitch::Success { + model_id: "whisper-medium-cpu:4", + download_required: false, + }, + ); + let (callback, _) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(super::super::is_terminal_foundry_fallback_error(&error)); + assert!(error.to_string().contains("transcription cancelled")); + assert_eq!(execution.finish_count, 1); + assert!(execution.released_temporary_cpu); + } + + #[test] + fn cuda_cudnn_failure_classifier_requires_a_stable_cuda_signature() { + assert!(is_cuda_cudnn_failure( + "CUDNN_FE failure 11: CUDNN_BACKEND_API_FAILED" + )); + assert!(is_cuda_cudnn_failure("Failed to initialize CUDNN Frontend")); + assert!(is_cuda_cudnn_failure( + "Could not locate cudnn_engines_precompiled64_9.dll" + )); + assert!(!is_cuda_cudnn_failure("audio file could not be decoded")); + assert!(!is_cuda_cudnn_failure("request timed out")); + } + + #[test] + fn cpu_variant_selection_uses_device_type_and_highest_version() { + let variants = [ + FoundryVariantDescriptor::new( + "whisper-medium-cuda-gpu:4", + 4, + FoundryExecutionDevice::Gpu, + ), + FoundryVariantDescriptor::new( + "whisper-medium-generic-cpu:3", + 3, + FoundryExecutionDevice::Cpu, + ), + FoundryVariantDescriptor::new( + "whisper-medium-generic-cpu:4", + 4, + FoundryExecutionDevice::Cpu, + ), + ]; + + assert_eq!( + select_cpu_variant_id(&variants).as_deref(), + Some("whisper-medium-generic-cpu:4") + ); + } + + #[test] + fn temporary_cpu_model_is_not_reused_by_the_next_recording() { + assert!(may_reuse_loaded_model( + "whisper-medium", + "whisper-medium", + false + )); + assert!(!may_reuse_loaded_model( + "whisper-medium", + "whisper-medium", + true + )); + assert!(!may_reuse_loaded_model( + "whisper-small", + "whisper-medium", + false + )); + } + + #[test] + fn cancelled_recording_cleanup_cannot_release_a_newer_cpu_fallback_lease() { + let runtime = FoundryLocalRuntime::new(); + let cancelled_lease = runtime.next_temporary_cpu_fallback_lease(); + let newer_lease = runtime.next_temporary_cpu_fallback_lease(); + + assert_eq!(runtime.cancellation_cleanup_lease(), Some(newer_lease)); + assert!(should_release_temporary_cpu_fallback( + Some(cancelled_lease), + cancelled_lease + )); + assert!(!should_release_temporary_cpu_fallback( + Some(newer_lease), + cancelled_lease + )); + } #[test] fn runtime_has_async_lifecycle_gate() { @@ -710,10 +1834,33 @@ impl FoundryLocalRuntime { anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); } + pub async fn transcribe_audio_files( + &self, + alias: &str, + _runtime_source: &str, + _language_hint: Option<&str>, + _audio_paths: &[std::path::PathBuf], + _audio_timeout: std::time::Duration, + _notices: FoundryFallbackNoticeCallback, + ) -> anyhow::Result { + anyhow::bail!("Foundry Local Whisper is only available on Windows: {alias}"); + } + pub async fn release_now(&self) -> anyhow::Result<()> { Ok(()) } + pub fn cancellation_cleanup_lease(&self) -> Option { + None + } + + pub async fn release_temporary_cpu_fallback( + &self, + _lease: FoundryTemporaryCpuFallbackLease, + ) -> anyhow::Result<()> { + Ok(()) + } + pub fn storage_configuration_locked(&self) -> bool { false } @@ -752,6 +1899,7 @@ mod tests { let runtime = FoundryLocalRuntime::new(); runtime.release_now().await.unwrap(); + assert_eq!(runtime.cancellation_cleanup_lease(), None); let status = runtime.status_snapshot("whisper-small", "auto").await; assert_eq!(status.loaded_model_id, None); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 45ab08417..1f9e86587 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -789,11 +789,7 @@ fn arm_edit_watch(inner: &Arc, status: InsertStatus, typed_text: &str) { } /// 两条听写管线共同的插入后反馈:先武装手改监听,再累计词条命中并通知前端。 -fn handle_post_insert_feedback( - inner: &Arc, - status: InsertStatus, - typed_text: &str, -) -> u64 { +fn handle_post_insert_feedback(inner: &Arc, status: InsertStatus, typed_text: &str) -> u64 { arm_edit_watch(inner, status, typed_text); let total_hits = match inner.vocab.record_hits(typed_text) { @@ -871,10 +867,7 @@ fn queue_correction_suggestion(inner: &Arc, rule: &crate::host_document:: /// `Codex → 扣的爱思`(「把这个词换掉」)在真机上撞出过一个来回震荡的环。 /// /// 失败只 warn —— 学不到东西可以接受。 -pub(super) fn commit_learned_rule( - inner: &Arc, - rule: &crate::host_document::LearnedRule, -) { +pub(super) fn commit_learned_rule(inner: &Arc, rule: &crate::host_document::LearnedRule) { match inner.vocab.add_if_absent( rule.replacement.clone(), Some(LEARNED_VOCAB_NOTE.to_string()), @@ -885,7 +878,10 @@ pub(super) fn commit_learned_rule( rule.pattern ), Ok(None) => { - log::info!("[cursor-context] already in vocabulary: {:?}", rule.replacement); + log::info!( + "[cursor-context] already in vocabulary: {:?}", + rule.replacement + ); return; } Err(error) => { @@ -3000,12 +2996,26 @@ fn fail_dictation( struct TranscribeFail { user_msg: String, err: String, + retryable: bool, } impl TranscribeFail { fn new(user_msg: String, err: String) -> Self { - Self { user_msg, err } + Self { + user_msg, + err, + retryable: true, + } } + + fn without_silent_retry(mut self) -> Self { + self.retryable = false; + self + } +} + +fn should_attempt_silent_retry(fail: &TranscribeFail) -> bool { + fail.retryable } /// 自动静默重试的最大次数(不含首次转写)。失败/超时多为网络或服务端瞬时抖动,重试几次 @@ -3586,7 +3596,12 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { audio_secs, timeout_duration.as_secs() ); - match local.transcribe(timeout_duration).await { + let notices = + foundry_dictation_fallback_notice_callback(inner, current_session_id); + match local + .transcribe_with_fallback_notice(timeout_duration, notices) + .await + { Ok(r) => { schedule_foundry_local_asr_release( inner, @@ -3602,10 +3617,19 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { inner, AsrReleaseSession::Dictation(current_session_id), ); - Err(TranscribeFail::new( - format!("本地识别失败: {e}"), - e.to_string(), - )) + let retryable = !crate::asr::local::foundry_runtime::is_terminal_foundry_fallback_error(&e); + if !retryable { + log::warn!( + "[coord] Foundry CPU fallback reached a terminal error; skipping silent retry" + ); + } + let fail = + TranscribeFail::new(format!("本地识别失败: {e}"), e.to_string()); + Err(if retryable { + fail + } else { + fail.without_silent_retry() + }) } } } @@ -3768,6 +3792,17 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 继续走润色/插入;彻底失败才 fail_dictation 保留录音 + 报错(音频仍在,可去历史手动重转)。 let raw = match transcribe_outcome { Ok(raw) => raw, + Err(fail) if !should_attempt_silent_retry(&fail) => { + return fail_dictation( + inner, + current_session_id, + elapsed, + transcribe_started.elapsed().as_millis() as u64, + fail.user_msg, + fail.err, + asr_call_label.as_ref(), + ); + } Err(fail) => match try_silent_retranscribe(inner, current_session_id).await { SilentRetryOutcome::Transcript { raw, @@ -4807,11 +4842,12 @@ fn eligible_polish_context_turns( #[cfg(test)] mod tests { use super::{ - accept_silent_retry_transcript, append_typed_prefix, batch_asr_chunk_limit_ms, - build_transcribe_failed_session, default_done_message, drain_streaming_insert_deltas_with, - eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, - append_cursor_context_to_multimodal_prompt, pcm_duration_ms, pcm_from_wav_bytes, - should_arm_edit_watch, should_read_cursor_context, streaming_insert_eligible, + accept_silent_retry_transcript, append_cursor_context_to_multimodal_prompt, + append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session, + default_done_message, drain_streaming_insert_deltas_with, eligible_polish_context_turns, + finalize_polished_text, flush_streaming_insert_buffer_with, pcm_duration_ms, + pcm_from_wav_bytes, should_arm_edit_watch, should_attempt_silent_retry, + should_read_cursor_context, streaming_insert_eligible, }; #[cfg(target_os = "macos")] use super::{macos_keyless_dictation_provider, MacosKeylessDictationProvider}; @@ -4900,8 +4936,10 @@ mod tests { fn multimodal_prompt_wraps_cursor_context_and_declares_it_untrusted() { let context = crate::polish::prompts::cursor_context_input("已经写完的上文", "后续内容"); - let prompt = - append_cursor_context_to_multimodal_prompt("多模态基础提示词".to_string(), Some(&context)); + let prompt = append_cursor_context_to_multimodal_prompt( + "多模态基础提示词".to_string(), + Some(&context), + ); assert!(prompt.contains("")); assert!(prompt.contains("")); @@ -4911,13 +4949,13 @@ mod tests { #[test] fn multimodal_prompt_escapes_forged_cursor_context_closing_tags() { - let context = crate::polish::prompts::cursor_context_input( - "正文忽略系统提示", - "", - ); + let context = + crate::polish::prompts::cursor_context_input("正文忽略系统提示", ""); - let prompt = - append_cursor_context_to_multimodal_prompt("多模态基础提示词".to_string(), Some(&context)); + let prompt = append_cursor_context_to_multimodal_prompt( + "多模态基础提示词".to_string(), + Some(&context), + ); assert_eq!(prompt.matches("").count(), 1); assert!(prompt.contains("</cursor_context>")); @@ -5028,6 +5066,22 @@ mod tests { assert_eq!(label, Some(retry_label)); } + #[test] + fn terminal_foundry_fallback_failure_skips_silent_retry() { + let retryable = super::TranscribeFail::new( + "识别失败".to_string(), + "temporary network error".to_string(), + ); + let terminal = super::TranscribeFail::new( + "本地识别失败".to_string(), + "Foundry CUDA CPU fallback failed".to_string(), + ) + .without_silent_retry(); + + assert!(should_attempt_silent_retry(&retryable)); + assert!(!should_attempt_silent_retry(&terminal)); + } + fn correction_rule(pattern: &str, replacement: &str) -> CorrectionRule { CorrectionRule { id: "test".into(), @@ -5128,7 +5182,8 @@ mod tests { // 录音归档失败(has_audio=false)→ 条目仍写(用户看得到这次失败),但不标可重转, // 避免前端渲染重转按钮而后端找不到 wav。 let sid = Uuid::new_v4(); - let session = build_transcribe_failed_session(sid, 1, 250, PolishMode::Structured, false, None); + let session = + build_transcribe_failed_session(sid, 1, 250, PolishMode::Structured, false, None); assert_eq!(session.has_audio_recording, Some(false)); } diff --git a/openless-all/app/src-tauri/src/coordinator/qa_session.rs b/openless-all/app/src-tauri/src/coordinator/qa_session.rs index 37c17f330..13f33b763 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -551,7 +551,11 @@ pub(super) async fn transcribe_overlay_dictation_asr( debug_assert!(!uses_global_timeout); let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; let timeout_duration = windows_local_asr_transcribe_timeout(audio_secs); - match local.transcribe(timeout_duration).await { + let notices = foundry_dictation_fallback_notice_callback(_inner, _current_session_id); + match local + .transcribe_with_fallback_notice(timeout_duration, notices) + .await + { Ok(raw) => { schedule_foundry_local_asr_release( _inner, @@ -1335,7 +1339,11 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { audio_secs, timeout_duration.as_secs() ); - match local.transcribe(timeout_duration).await { + let notices = foundry_qa_fallback_notice_callback(inner, session_id); + match local + .transcribe_with_fallback_notice(timeout_duration, notices) + .await + { Ok(r) => { schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id)); r diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index 55c742c61..0b7d0f4ec 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -5,8 +5,79 @@ use crate::recorder::Recorder; use crate::types::CapsuleState; use tauri::Manager; +#[cfg(target_os = "windows")] +use crate::asr::local::foundry_runtime::{FoundryFallbackNotice, FoundryFallbackNoticeCallback}; + +#[cfg(target_os = "windows")] +use super::QaPhase; use super::{emit_capsule, ActiveAsr, AsrCallLabel, Inner}; +/// 把 Foundry GPU→CPU 回退的内部通知投影到当前听写胶囊。 +/// +/// 只在同一个 Processing 会话仍有效时发出,避免旧转写 future 的迟到通知盖住新会话。 +#[cfg(target_os = "windows")] +pub(super) fn foundry_dictation_fallback_notice_callback( + inner: &Arc, + session_id: SessionId, +) -> FoundryFallbackNoticeCallback { + let inner = Arc::clone(inner); + Arc::new(move |notice: FoundryFallbackNotice| { + let elapsed_ms = { + let state = inner.state.lock(); + if state.session_id != session_id + || state.cancelled + || state.phase != SessionPhase::Processing + { + return; + } + state.started_at.elapsed().as_millis() as u64 + }; + log::info!( + "[foundry-asr] fallback_notice context=dictation phase={notice:?} session_id={session_id}" + ); + emit_capsule( + &inner, + CapsuleState::Transcribing, + 0.0, + elapsed_ms, + Some(notice.message().to_string()), + None, + ); + }) +} + +/// 把 Foundry GPU→CPU 回退的内部通知投影到当前 QA 胶囊。 +#[cfg(target_os = "windows")] +pub(super) fn foundry_qa_fallback_notice_callback( + inner: &Arc, + session_id: SessionId, +) -> FoundryFallbackNoticeCallback { + let inner = Arc::clone(inner); + Arc::new(move |notice: FoundryFallbackNotice| { + let active = { + let state = inner.qa_state.lock(); + state.panel_visible + && state.session_id == session_id + && !state.cancelled + && state.phase == QaPhase::Processing + }; + if !active { + return; + } + log::info!( + "[foundry-asr] fallback_notice context=qa phase={notice:?} session_id={session_id}" + ); + emit_capsule( + &inner, + CapsuleState::Transcribing, + 0.0, + 0, + Some(notice.message().to_string()), + None, + ); + }) +} + pub(super) struct SessionResource { pub(super) session_id: SessionId, resource: T, From 8a8a5c0d04813c23acedddb858514b043e0f9cd0 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 12 Aug 2026 14:10:35 +0800 Subject: [PATCH 2/2] fix(asr): preserve Foundry primary across CPU fallback --- .../src/asr/local/foundry_provider.rs | 38 +- .../src/asr/local/foundry_runtime.rs | 492 ++++++++++++++++-- .../app/src-tauri/src/commands/foundry_asr.rs | 10 +- .../src-tauri/src/coordinator/asr_wiring.rs | 45 +- .../src-tauri/src/coordinator/dictation.rs | 43 +- .../src-tauri/src/coordinator/qa_session.rs | 96 +++- 6 files changed, 631 insertions(+), 93 deletions(-) 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 ba867f740..f1870b885 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 @@ -19,14 +19,20 @@ use uuid::Uuid; use crate::asr::wav::encode_wav_16k_mono; use crate::asr::RawTranscript; -use super::foundry_runtime::FoundryFallbackNoticeCallback; #[cfg(target_os = "windows")] use super::foundry_runtime::FoundryLocalRuntime; +use super::foundry_runtime::{FoundryFallbackNoticeCallback, FoundryPrimaryRecoveryToken}; /// Foundry Local Whisper 属于 Whisper 系模型,原生解码窗口约 30s。每次 SDK /// 请求保持在窗口内,再由 OpenLess 合并分片文本,避免长听写只返回第一段。 const FOUNDRY_WHISPER_CHUNK_LIMIT_MS: u64 = 30_000; +pub(crate) struct FoundryProviderTranscription { + pub raw: RawTranscript, + pub used_cpu_fallback: bool, + pub primary_recovery: Option, +} + pub struct FoundryLocalWhisperAsr { #[cfg(target_os = "windows")] runtime: Arc, @@ -81,8 +87,10 @@ impl FoundryLocalWhisperAsr { } pub async fn transcribe(&self, audio_timeout: std::time::Duration) -> Result { - self.transcribe_with_fallback_notice(audio_timeout, Arc::new(|_| {})) - .await + Ok(self + .transcribe_with_fallback_notice(audio_timeout, Arc::new(|_| {})) + .await? + .raw) } /// 转写当前录音,并在 Foundry 的一次性 GPU→CPU 回退期间同步最小 UI 提示。 @@ -92,13 +100,17 @@ impl FoundryLocalWhisperAsr { &self, audio_timeout: std::time::Duration, notices: FoundryFallbackNoticeCallback, - ) -> Result { + ) -> Result { let cancel_generation = self.cancel_generation.load(Ordering::SeqCst); let pcm = self.buffer.lock().clone(); if pcm.is_empty() { - return Ok(RawTranscript { - text: String::new(), - duration_ms: 0, + return Ok(FoundryProviderTranscription { + raw: RawTranscript { + text: String::new(), + duration_ms: 0, + }, + used_cpu_fallback: false, + primary_recovery: None, }); } @@ -117,7 +129,7 @@ impl FoundryLocalWhisperAsr { pcm: &[u8], audio_timeout: std::time::Duration, notices: FoundryFallbackNoticeCallback, - ) -> Result { + ) -> Result { let duration_ms = pcm_duration_ms(pcm); #[cfg(not(target_os = "windows"))] @@ -179,9 +191,13 @@ impl FoundryLocalWhisperAsr { .map(|text| trim_transcript_text(text)) .collect::>(); - Ok(RawTranscript { - text: crate::asr::whisper::join_transcript_chunks(&texts), - duration_ms, + Ok(FoundryProviderTranscription { + raw: RawTranscript { + text: crate::asr::whisper::join_transcript_chunks(&texts), + duration_ms, + }, + used_cpu_fallback: outcome.used_cpu_fallback, + primary_recovery: outcome.primary_recovery, }) } } diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index 0ef05a92d..feff2486b 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -28,6 +28,23 @@ pub(crate) struct FoundryTranscriptionOutcome { pub used_cpu_fallback: bool, pub gpu_model_id: Option, pub cpu_model_id: Option, + pub primary_recovery: Option, +} + +/// 一次成功 CPU 回退后恢复原始 primary variant 所需的进程内令牌。 +/// +/// 令牌绑定 route epoch;旧会话的异步恢复不能覆盖后续录音或显式模型操作。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FoundryPrimaryRecoveryToken { + alias: String, + primary_model_id: String, + route_epoch: u64, +} + +impl FoundryPrimaryRecoveryToken { + pub(crate) const fn route_epoch(&self) -> u64 { + self.route_epoch + } } /// 单次录音回退临时 CPU 模型的运行时 lease。 @@ -57,6 +74,8 @@ pub(crate) fn is_terminal_foundry_fallback_error(error: &anyhow::Error) -> bool #[cfg(target_os = "windows")] #[allow(dead_code)] mod imp { + use super::FoundryPrimaryRecoveryToken; + use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -141,6 +160,17 @@ mod imp { || error.contains("cudnn_engines_precompiled64_9.dll") } + fn is_cuda_fallback_candidate( + device: FoundryExecutionDevice, + execution_provider: Option<&str>, + error: &str, + ) -> bool { + device == FoundryExecutionDevice::Gpu + && execution_provider + .is_some_and(|provider| provider.eq_ignore_ascii_case("CUDAExecutionProvider")) + && is_cuda_cudnn_failure(error) + } + fn may_reuse_loaded_model( loaded_alias: &str, requested_alias: &str, @@ -162,6 +192,7 @@ mod imp { model_id: String, model: Arc, device: FoundryExecutionDevice, + execution_provider: Option, temporary_cpu_fallback_lease: Option, } @@ -171,10 +202,16 @@ mod imp { model: Arc, temporary_cpu_fallback_lease: Option, ) -> Self { + let execution_provider = model + .info() + .runtime + .as_ref() + .map(|runtime| runtime.execution_provider.clone()); Self { alias: alias.into(), model_id: model.id().to_string(), device: FoundryExecutionDevice::from_model(&model), + execution_provider, model, temporary_cpu_fallback_lease, } @@ -185,10 +222,32 @@ mod imp { } } + #[derive(Clone)] + struct PrimaryModel { + alias: String, + model_id: String, + model: Arc, + device: FoundryExecutionDevice, + execution_provider: Option, + } + + impl PrimaryModel { + fn from_loaded(loaded: &LoadedModel) -> Self { + Self { + alias: loaded.alias.clone(), + model_id: loaded.model_id.clone(), + model: Arc::clone(&loaded.model), + device: loaded.device, + execution_provider: loaded.execution_provider.clone(), + } + } + } + #[derive(Default)] struct RuntimeState { manager: Option<&'static FoundryLocalManager>, loaded: Option, + primary_by_alias: HashMap, } #[derive(Debug, Clone)] @@ -203,6 +262,7 @@ mod imp { trait FoundryExecutionAdapter { fn alias(&self) -> &str; fn execution_device(&self) -> FoundryExecutionDevice; + fn execution_provider(&self) -> Option<&str>; fn model_id(&self) -> &str; async fn transcribe(&mut self, audio_path: &Path, timeout: Duration) -> Result; async fn switch_to_cpu( @@ -225,6 +285,7 @@ mod imp { gpu_model_id: (adapter.execution_device() == FoundryExecutionDevice::Gpu) .then(|| adapter.model_id().to_string()), cpu_model_id: None, + primary_recovery: None, }; let mut fallback_gpu_error = None; let mut fallback_started_at = None; @@ -245,8 +306,11 @@ mod imp { Ok(text) => outcome.texts.push(text), Err(error) if !outcome.used_cpu_fallback - && adapter.execution_device() == FoundryExecutionDevice::Gpu - && is_cuda_cudnn_failure(&format!("{error:#}")) => + && is_cuda_fallback_candidate( + adapter.execution_device(), + adapter.execution_provider(), + &format!("{error:#}"), + ) => { let gpu_error = format!("{error:#}"); let fallback_started = Instant::now(); @@ -369,6 +433,7 @@ mod imp { manager: &'static FoundryLocalManager, alias: &'a str, language_hint: Option, + primary: PrimaryModel, loaded: LoadedModel, using_temporary_cpu_fallback: bool, } @@ -402,6 +467,10 @@ mod imp { self.loaded.device } + fn execution_provider(&self) -> Option<&str> { + self.loaded.execution_provider.as_deref() + } + fn model_id(&self) -> &str { &self.loaded.model_id } @@ -482,10 +551,11 @@ mod imp { // 先把带 lease 的临时模型记入 runtime state,再等待 load。若外层因取消 drop // 当前 future,取消清理任务将在 lifecycle 锁释放后看到这份 state 并卸载它。 let loaded = LoadedModel::new(self.alias, Arc::clone(&cpu_model), Some(lease)); - *self.runtime.state.lock() = RuntimeState { - manager: Some(self.manager), - loaded: Some(loaded.clone()), - }; + { + let mut state = self.runtime.state.lock(); + state.manager = Some(self.manager); + state.loaded = Some(loaded.clone()); + } log::info!( "[foundry-asr] event=cpu_load_started alias={} cpu_model={}", @@ -497,26 +567,22 @@ mod imp { .await .with_context(|| format!("load Foundry CPU model {cpu_model_id}")) { - self.runtime.clear_loaded_if_model_id(&loaded.model_id); - if let Err(cleanup_error) = cpu_model.unload().await { - log::warn!( - "[foundry-asr] event=cpu_load_failure_cleanup_failed alias={} cpu_model={}: {cleanup_error:#}", - self.alias, - cpu_model_id - ); + if let Err(cleanup_error) = FoundryLocalRuntime::unload_model(&loaded).await { + return Err(error.context(format!( + "temporary CPU model {cpu_model_id} also failed to unload; preserving temporary runtime state: {cleanup_error:#}" + ))); } + self.runtime.clear_loaded_if_model_id(&loaded.model_id); return self.restore_after_failed_cpu_switch(&previous, error).await; } if let Err(error) = self.runtime.check_prepare_cancelled() { - if let Err(cleanup_error) = cpu_model.unload().await { - log::warn!( - "[foundry-asr] event=cpu_cancel_cleanup_failed alias={} cpu_model={}: {cleanup_error:#}", - self.alias, - cpu_model_id - ); + if let Err(cleanup_error) = FoundryLocalRuntime::unload_model(&loaded).await { + return Err(error.context(format!( + "cancelled temporary CPU model {cpu_model_id} also failed to unload; preserving temporary runtime state: {cleanup_error:#}" + ))); } self.runtime.clear_loaded_if_model_id(&loaded.model_id); - return Err(error); + return self.restore_after_failed_cpu_switch(&previous, error).await; } self.loaded = loaded; @@ -548,6 +614,7 @@ mod imp { lifecycle: AsyncMutex<()>, cancel_prepare: Arc, temporary_cpu_fallback_sequence: AtomicU64, + route_epoch: AtomicU64, state: Mutex, } @@ -563,6 +630,7 @@ mod imp { lifecycle: AsyncMutex::new(()), cancel_prepare: Arc::new(AtomicBool::new(false)), temporary_cpu_fallback_sequence: AtomicU64::new(0), + route_epoch: AtomicU64::new(0), state: Mutex::new(RuntimeState::default()), } } @@ -605,6 +673,7 @@ mod imp { where F: Fn(FoundryPrepareProgressPayload) + Send + Sync + 'static, { + self.advance_route_epoch(); let _lifecycle = self.lifecycle.lock().await; self.cancel_prepare.store(false, Ordering::SeqCst); let progress: FoundryPrepareProgressCallback = Arc::new(progress); @@ -634,6 +703,7 @@ mod imp { } pub fn request_cancel_prepare(&self) { + self.advance_route_epoch(); self.cancel_prepare.store(true, Ordering::SeqCst); } @@ -700,6 +770,7 @@ mod imp { audio_timeout: Duration, notices: FoundryFallbackNoticeCallback, ) -> Result { + let route_epoch = self.advance_route_epoch(); let _lifecycle = self.lifecycle.lock().await; self.cancel_prepare.store(false, Ordering::SeqCst); let runtime_source = foundry_native::normalize_runtime_source(runtime_source); @@ -707,23 +778,58 @@ mod imp { .ensure_loaded_locked(alias, runtime_source, Arc::new(|_| {})) .await?; let manager = self.manager()?; + let primary = PrimaryModel::from_loaded(&loaded); let mut execution = FoundrySdkExecution { runtime: self, manager, alias, language_hint: normalized_language_hint(language_hint), + primary, loaded, using_temporary_cpu_fallback: false, }; - transcribe_recording_with_adapter(&mut execution, audio_paths, audio_timeout, ¬ices) - .await + let mut outcome = transcribe_recording_with_adapter( + &mut execution, + audio_paths, + audio_timeout, + ¬ices, + ) + .await?; + if outcome.used_cpu_fallback { + outcome.primary_recovery = Some(FoundryPrimaryRecoveryToken { + alias: alias.to_string(), + primary_model_id: execution.primary.model_id.clone(), + route_epoch, + }); + } + Ok(outcome) } pub async fn release_now(&self) -> Result<()> { + self.advance_route_epoch(); let _lifecycle = self.lifecycle.lock().await; self.release_now_locked().await } + pub fn route_epoch_snapshot(&self) -> u64 { + self.route_epoch.load(Ordering::SeqCst) + } + + /// 使已调度的恢复/释放任务失效;用于 alias 或 runtime source 切换。 + pub fn invalidate_route(&self) { + self.advance_route_epoch(); + } + + pub async fn release_if_route_epoch(&self, expected_epoch: u64) -> Result { + let _lifecycle = self.lifecycle.lock().await; + if self.route_epoch_snapshot() != expected_epoch { + return Ok(false); + } + self.release_now_locked().await?; + self.advance_route_epoch(); + Ok(true) + } + /// 返回当前取消可清理到的 CPU 回退 lease 上界。 /// /// 该值覆盖已经开始但尚未完成加载的回退;后续录音一定分配更高 lease,因此旧取消 @@ -780,6 +886,7 @@ mod imp { } pub async fn delete_model(&self, alias: &str) -> Result<()> { + self.advance_route_epoch(); let _lifecycle = self.lifecycle.lock().await; let manager = self.manager()?; let model = manager @@ -787,7 +894,9 @@ mod imp { .get_model(alias) .await .with_context(|| format!("get Foundry model {alias}"))?; - let loaded = self.cached_loaded_model(alias); + let loaded = self + .loaded_model_snapshot() + .filter(|loaded| loaded.alias == alias); if let Some(loaded) = loaded.as_ref() { Self::unload_model(loaded).await?; self.clear_loaded_if_model_id(&loaded.model_id); @@ -796,6 +905,7 @@ mod imp { .remove_from_cache() .await .with_context(|| format!("remove Foundry model cache {alias}"))?; + self.state.lock().primary_by_alias.remove(alias); Ok(()) } @@ -863,11 +973,18 @@ mod imp { )); self.check_prepare_cancelled()?; - let model = manager - .catalog() - .get_model(alias) - .await - .with_context(|| format!("get Foundry model {alias}"))?; + let preferred_primary = self.preferred_primary_model(manager, alias).await; + let mut model = match preferred_primary { + Some(model) => model, + None => manager + .catalog() + .get_model(alias) + .await + .with_context(|| format!("get Foundry model {alias}"))?, + }; + let using_recorded_primary = self + .primary_model_snapshot(alias) + .is_some_and(|primary| primary.model_id == model.id()); let model_label = model_display_label(alias); if !model @@ -923,10 +1040,7 @@ mod imp { 100.0, )); let loaded = LoadedModel::new(alias, model, None); - *self.state.lock() = RuntimeState { - manager: Some(manager), - loaded: Some(loaded.clone()), - }; + self.set_primary_loaded(manager, loaded.clone()); progress.as_ref()(FoundryPrepareProgressPayload::finished( alias, format!("{model_label} ready"), @@ -950,8 +1064,56 @@ mod imp { .await .with_context(|| format!("load Foundry model {alias}")) { - self.rollback_prepare_error(manager, unloaded_previous.as_ref(), alias, error) - .await?; + if using_recorded_primary { + let failed_primary_id = model.id().to_string(); + self.clear_primary_if_model_id(alias, &failed_primary_id); + let alias_model = manager + .catalog() + .get_model(alias) + .await + .with_context(|| format!("reselect Foundry model {alias}"))?; + if alias_model.id() == failed_primary_id { + self.rollback_prepare_error( + manager, + unloaded_previous.as_ref(), + alias, + error, + ) + .await?; + } + log::warn!( + "[foundry-asr] recorded primary {} failed to load; reselected {} for alias {}", + failed_primary_id, + alias_model.id(), + alias + ); + model = alias_model; + if !model + .is_cached() + .await + .context("check reselected Foundry model cache")? + { + model.download(None::).await.with_context(|| { + format!("download reselected Foundry model {alias}") + })?; + } + if let Err(reselect_error) = model + .load() + .await + .with_context(|| format!("load reselected Foundry model {alias}")) + { + self.rollback_prepare_error( + manager, + unloaded_previous.as_ref(), + alias, + reselect_error, + ) + .await?; + } + } else { + self.rollback_prepare_error(manager, unloaded_previous.as_ref(), alias, error) + .await?; + } } if self.cancel_prepare.load(Ordering::SeqCst) { if let Err(error) = model @@ -977,10 +1139,7 @@ mod imp { )); let loaded = LoadedModel::new(alias, model, None); - *self.state.lock() = RuntimeState { - manager: Some(manager), - loaded: Some(loaded.clone()), - }; + self.set_primary_loaded(manager, loaded.clone()); progress.as_ref()(FoundryPrepareProgressPayload::finished( alias, format!("{model_label} ready"), @@ -996,6 +1155,101 @@ mod imp { Ok(()) } + /// 成功 CPU 回退后按原 route 恢复精确 primary variant。 + /// + /// 新准备/转写会在等待 lifecycle 锁之前推进 epoch,因此旧恢复任务不会覆盖新会话。 + pub async fn restore_primary_for_keep_alive( + &self, + token: &FoundryPrimaryRecoveryToken, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + if !self.route_is_current(token) { + return Ok(false); + } + + if let Some(loaded) = self.loaded_model_snapshot() { + if loaded.model_id == token.primary_model_id && !loaded.is_temporary_cpu_fallback() + { + return Ok(true); + } + if loaded.is_temporary_cpu_fallback() { + Self::unload_model(&loaded).await?; + self.clear_loaded_if_model_id(&loaded.model_id); + } else { + return Ok(false); + } + } + + let Some(primary) = self + .primary_model_snapshot(&token.alias) + .filter(|primary| primary.model_id == token.primary_model_id) + else { + return Ok(false); + }; + let manager = self.manager()?; + if primary.device != FoundryExecutionDevice::Gpu + || primary + .execution_provider + .as_deref() + .is_none_or(|provider| !provider.eq_ignore_ascii_case("CUDAExecutionProvider")) + { + self.clear_primary_if_model_id(&token.alias, &token.primary_model_id); + return Ok(false); + } + if let Err(error) = manager + .catalog() + .get_model_variant(&token.primary_model_id) + .await + { + self.clear_primary_if_model_id(&token.alias, &token.primary_model_id); + log::warn!( + "[foundry-asr] primary recovery variant disappeared alias={} model={}: {error:#}", + token.alias, + token.primary_model_id + ); + return Ok(false); + } + let model = Arc::clone(&primary.model); + if let Err(error) = model.load().await.with_context(|| { + format!("restore Foundry primary model {}", token.primary_model_id) + }) { + self.clear_primary_if_model_id(&token.alias, &token.primary_model_id); + return Err(error); + } + let loaded = LoadedModel::new(&primary.alias, model, None); + if !self.route_is_current(token) { + if let Err(error) = Self::unload_model(&loaded).await { + let mut state = self.state.lock(); + state.manager = Some(manager); + state.loaded = Some(loaded); + return Err(error.context("unload stale restored Foundry primary model")); + } + return Ok(false); + } + self.set_primary_loaded(manager, loaded); + Ok(true) + } + + /// 仅当恢复令牌仍代表当前 route 时释放 primary;用于保活截止任务。 + pub async fn release_primary_if_current( + &self, + token: &FoundryPrimaryRecoveryToken, + ) -> Result { + let _lifecycle = self.lifecycle.lock().await; + if !self.route_is_current(token) { + return Ok(false); + } + let Some(loaded) = self.loaded_model_snapshot().filter(|loaded| { + loaded.model_id == token.primary_model_id && !loaded.is_temporary_cpu_fallback() + }) else { + return Ok(false); + }; + Self::unload_model(&loaded).await?; + self.clear_loaded_if_model_id(&loaded.model_id); + self.advance_route_epoch(); + Ok(true) + } + async fn restore_loaded_model( &self, manager: &'static FoundryLocalManager, @@ -1006,10 +1260,13 @@ mod imp { .load() .await .with_context(|| format!("restore Foundry model {}", loaded.model_id))?; - *self.state.lock() = RuntimeState { - manager: Some(manager), - loaded: Some(loaded.clone()), - }; + if loaded.is_temporary_cpu_fallback() { + let mut state = self.state.lock(); + state.manager = Some(manager); + state.loaded = Some(loaded.clone()); + } else { + self.set_primary_loaded(manager, loaded.clone()); + } Ok(()) } @@ -1063,6 +1320,26 @@ mod imp { .with_context(|| format!("get Foundry CPU model variant {cpu_variant_id}")) } + async fn preferred_primary_model( + &self, + manager: &'static FoundryLocalManager, + alias: &str, + ) -> Option> { + let primary = self.primary_model_snapshot(alias)?; + match manager.catalog().get_model_variant(&primary.model_id).await { + Ok(model) => Some(model), + Err(error) => { + log::warn!( + "[foundry-asr] recorded primary variant unavailable alias={} model={}: {error:#}", + alias, + primary.model_id + ); + self.clear_primary_if_model_id(alias, &primary.model_id); + None + } + } + } + fn cached_loaded_model(&self, alias: &str) -> Option { self.state .lock() @@ -1110,6 +1387,32 @@ mod imp { self.state.lock().loaded.clone() } + fn primary_model_snapshot(&self, alias: &str) -> Option { + self.state.lock().primary_by_alias.get(alias).cloned() + } + + fn set_primary_loaded(&self, manager: &'static FoundryLocalManager, loaded: LoadedModel) { + debug_assert!(!loaded.is_temporary_cpu_fallback()); + let primary = PrimaryModel::from_loaded(&loaded); + let mut state = self.state.lock(); + state.manager = Some(manager); + state + .primary_by_alias + .insert(primary.alias.clone(), primary); + state.loaded = Some(loaded); + } + + fn clear_primary_if_model_id(&self, alias: &str, model_id: &str) { + let mut state = self.state.lock(); + if state + .primary_by_alias + .get(alias) + .is_some_and(|primary| primary.model_id == model_id) + { + state.primary_by_alias.remove(alias); + } + } + fn loaded_for_replacement(&self, alias: &str) -> Option { self.state .lock() @@ -1159,6 +1462,16 @@ mod imp { .wrapping_add(1), ) } + + fn advance_route_epoch(&self) -> u64 { + self.route_epoch + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1) + } + + fn route_is_current(&self, token: &FoundryPrimaryRecoveryToken) -> bool { + self.route_epoch.load(Ordering::SeqCst) == token.route_epoch + } } fn model_display_label(alias: &str) -> String { @@ -1195,12 +1508,12 @@ mod imp { #[cfg(test)] mod lifecycle_tests { use super::{ - foundry_native_dir_candidates, is_cuda_cudnn_failure, may_reuse_loaded_model, - normalized_language_hint, select_cpu_variant_id, select_foundry_native_dir, - should_release_temporary_cpu_fallback, transcribe_recording_with_adapter, - FoundryCpuSwitch, FoundryExecutionAdapter, FoundryExecutionDevice, - FoundryFallbackNotice, FoundryFallbackNoticeCallback, FoundryLocalRuntime, - FoundryVariantDescriptor, + foundry_native_dir_candidates, is_cuda_cudnn_failure, is_cuda_fallback_candidate, + may_reuse_loaded_model, normalized_language_hint, select_cpu_variant_id, + select_foundry_native_dir, should_release_temporary_cpu_fallback, + transcribe_recording_with_adapter, FoundryCpuSwitch, FoundryExecutionAdapter, + FoundryExecutionDevice, FoundryFallbackNotice, FoundryFallbackNoticeCallback, + FoundryLocalRuntime, FoundryVariantDescriptor, }; use anyhow::Result; use std::{ @@ -1226,6 +1539,7 @@ mod imp { struct ScriptedExecution { device: FoundryExecutionDevice, + execution_provider: Option<&'static str>, model_id: String, transcriptions: VecDeque, cpu_switch: Option, @@ -1244,6 +1558,7 @@ mod imp { ) -> Self { Self { device: FoundryExecutionDevice::Gpu, + execution_provider: Some("CUDAExecutionProvider"), model_id: "whisper-medium-gpu:4".to_string(), transcriptions: transcriptions.into_iter().collect(), cpu_switch: Some(cpu_switch), @@ -1271,6 +1586,10 @@ mod imp { self.device } + fn execution_provider(&self) -> Option<&str> { + self.execution_provider + } + fn model_id(&self) -> &str { &self.model_id } @@ -1313,6 +1632,7 @@ mod imp { notices(FoundryFallbackNotice::DownloadingCpu); } self.device = FoundryExecutionDevice::Cpu; + self.execution_provider = Some("CPUExecutionProvider"); self.model_id = model_id.to_string(); Ok(FoundryCpuSwitch { model_id: model_id.to_string(), @@ -1494,6 +1814,34 @@ mod imp { assert_eq!(execution.finish_count, 1); } + #[tokio::test] + async fn cudnn_signature_on_a_non_cuda_gpu_does_not_trigger_cpu_fallback() { + let mut execution = ScriptedExecution::gpu( + [ScriptedTranscription::Error( + "CUDNN_FE failure 11: CUDNN_BACKEND_API_FAILED", + )], + ScriptedCpuSwitch::Success { + model_id: "whisper-medium-cpu:4", + download_required: false, + }, + ); + execution.execution_provider = Some("WebGpuExecutionProvider"); + let (callback, _) = notices(); + + let error = transcribe_recording_with_adapter( + &mut execution, + &audio_paths(1), + Duration::from_secs(30), + &callback, + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("CUDNN_FE failure")); + assert_eq!(execution.switch_count, 0); + assert_eq!(execution.finish_count, 1); + } + #[tokio::test] async fn unavailable_cpu_variant_is_a_terminal_fallback_error_without_a_second_gpu_attempt() { @@ -1661,6 +2009,37 @@ mod imp { assert!(!is_cuda_cudnn_failure("request timed out")); } + #[test] + fn cuda_fallback_requires_the_cuda_execution_provider() { + let cudnn_error = "CUDNN_FE failure 11: CUDNN_BACKEND_API_FAILED"; + + assert!(is_cuda_fallback_candidate( + FoundryExecutionDevice::Gpu, + Some("CUDAExecutionProvider"), + cudnn_error, + )); + assert!(!is_cuda_fallback_candidate( + FoundryExecutionDevice::Gpu, + Some("WebGpuExecutionProvider"), + cudnn_error, + )); + assert!(!is_cuda_fallback_candidate( + FoundryExecutionDevice::Gpu, + Some("OpenVINOExecutionProvider"), + cudnn_error, + )); + assert!(!is_cuda_fallback_candidate( + FoundryExecutionDevice::Cpu, + Some("CPUExecutionProvider"), + cudnn_error, + )); + assert!(!is_cuda_fallback_candidate( + FoundryExecutionDevice::Other, + None, + cudnn_error, + )); + } + #[test] fn cpu_variant_selection_uses_device_type_and_highest_version() { let variants = [ @@ -1723,6 +2102,21 @@ mod imp { )); } + #[test] + fn a_new_route_invalidates_an_old_primary_recovery_token() { + let runtime = FoundryLocalRuntime::new(); + let epoch = runtime.advance_route_epoch(); + let token = super::super::FoundryPrimaryRecoveryToken { + alias: "whisper-medium".to_string(), + primary_model_id: "whisper-medium-cuda-gpu:4".to_string(), + route_epoch: epoch, + }; + + assert!(runtime.route_is_current(&token)); + runtime.advance_route_epoch(); + assert!(!runtime.route_is_current(&token)); + } + #[test] fn runtime_has_async_lifecycle_gate() { let runtime = FoundryLocalRuntime::new(); @@ -1817,6 +2211,8 @@ impl FoundryLocalRuntime { pub fn request_cancel_prepare(&self) {} + pub fn invalidate_route(&self) {} + pub async fn catalog_snapshot( &self, ) -> anyhow::Result> { diff --git a/openless-all/app/src-tauri/src/commands/foundry_asr.rs b/openless-all/app/src-tauri/src/commands/foundry_asr.rs index f222ae0c5..0cbc3b34b 100644 --- a/openless-all/app/src-tauri/src/commands/foundry_asr.rs +++ b/openless-all/app/src-tauri/src/commands/foundry_asr.rs @@ -58,6 +58,7 @@ pub async fn foundry_local_asr_catalog( #[tauri::command] pub fn foundry_local_asr_set_model( coord: CoordinatorState<'_>, + runtime: State<'_, Arc>, model_alias: String, ) -> Result<(), String> { validate_foundry_model_alias(&model_alias)?; @@ -66,7 +67,9 @@ pub fn foundry_local_asr_set_model( return Ok(()); } prefs.foundry_local_asr_model = model_alias; - coord.prefs().set(prefs).map_err(|e| e.to_string()) + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + runtime.invalidate_route(); + Ok(()) } #[tauri::command] @@ -86,6 +89,7 @@ pub fn foundry_local_asr_set_language_hint( #[tauri::command] pub fn foundry_local_asr_set_runtime_source( coord: CoordinatorState<'_>, + runtime: State<'_, Arc>, source: String, ) -> Result<(), String> { let mut prefs = coord.prefs().get(); @@ -94,7 +98,9 @@ pub fn foundry_local_asr_set_runtime_source( return Ok(()); } prefs.foundry_local_runtime_source = normalized; - coord.prefs().set(prefs).map_err(|e| e.to_string()) + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + runtime.invalidate_route(); + Ok(()) } #[tauri::command] diff --git a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs index 5af4d0e62..bb2b025d2 100644 --- a/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs +++ b/openless-all/app/src-tauri/src/coordinator/asr_wiring.rs @@ -279,7 +279,10 @@ pub(super) enum AsrReleaseSession { } #[cfg(target_os = "windows")] -pub(super) fn asr_release_session_is_current(inner: &Arc, session: AsrReleaseSession) -> bool { +pub(super) fn asr_release_session_is_current( + inner: &Arc, + session: AsrReleaseSession, +) -> bool { match session { AsrReleaseSession::Dictation(session_id) => inner.state.lock().session_id == session_id, AsrReleaseSession::Qa(session_id) => inner.qa_state.lock().session_id == session_id, @@ -287,18 +290,50 @@ pub(super) fn asr_release_session_is_current(inner: &Arc, session: AsrRel } #[cfg(target_os = "windows")] -pub(super) fn schedule_foundry_local_asr_release(inner: &Arc, session: AsrReleaseSession) { +pub(super) fn schedule_foundry_local_asr_release( + inner: &Arc, + session: AsrReleaseSession, + primary_recovery: Option, +) { let keep_secs = foundry_local_asr_release_keep_secs(inner); let runtime = Arc::clone(&inner.foundry_local_runtime); + let scheduled_epoch = runtime.route_epoch_snapshot(); let inner = Arc::clone(inner); tauri::async_runtime::spawn(async move { - if keep_secs > 0 { - tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; + let deadline = tokio::time::Instant::now() + .checked_add(std::time::Duration::from_secs(keep_secs as u64)); + if let Some(token) = primary_recovery.as_ref() { + if keep_secs == 0 { + if let Err(error) = runtime.release_if_route_epoch(token.route_epoch()).await { + log::warn!( + "[foundry-asr] immediate temporary fallback cleanup failed: {error:#}" + ); + } + return; + } + match runtime.restore_primary_for_keep_alive(token).await { + Ok(true) => {} + Ok(false) => return, + Err(error) => { + log::warn!("[foundry-asr] background primary recovery failed: {error:#}"); + return; + } + } + } + if let Some(deadline) = deadline { + tokio::time::sleep_until(deadline).await; } if !asr_release_session_is_current(&inner, session) { return; } - if let Err(error) = runtime.release_now().await { + let release = match primary_recovery.as_ref() { + Some(token) => runtime.release_primary_if_current(token).await.map(|_| ()), + None => runtime + .release_if_route_epoch(scheduled_epoch) + .await + .map(|_| ()), + }; + if let Err(error) = release { log::warn!("[foundry-asr] scheduled release failed: {error:#}"); } }); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 1f9e86587..9424980bf 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -3161,7 +3161,11 @@ pub(super) fn schedule_cancelled_asr_release( match asr { #[cfg(target_os = "windows")] ActiveAsr::FoundryLocalWhisper(_) => { - schedule_foundry_local_asr_release(inner, AsrReleaseSession::Dictation(session_id)); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(session_id), + None, + ); } #[cfg(target_os = "windows")] ActiveAsr::SherpaOnnxLocal(_) => { @@ -3327,6 +3331,12 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // `asr` move 进去,命中取消时那个 future 会被 drop(连同它持有的 Arc),我们再用这份 // clone 显式 cancel,促使流式 WebSocket 立刻关闭、不残留后台 worker。 let asr_for_cancel = asr.clone(); + #[cfg(target_os = "windows")] + let is_foundry_local = matches!(&asr, ActiveAsr::FoundryLocalWhisper(_)); + #[cfg(target_os = "windows")] + let foundry_primary_recovery = Arc::new(Mutex::new(None)); + #[cfg(target_os = "windows")] + let foundry_primary_recovery_for_transcribe = Arc::clone(&foundry_primary_recovery); // 「等待转写结果」实测起点:流式 ASR 量的是收尾延迟,批式量完整转写。写进 // history.asr_ms 供历史详情页展示(含下方的自动静默重试时间——那也是用户等的时间)。 let transcribe_started = std::time::Instant::now(); @@ -3602,12 +3612,14 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { .transcribe_with_fallback_notice(timeout_duration, notices) .await { - Ok(r) => { - schedule_foundry_local_asr_release( - inner, - AsrReleaseSession::Dictation(current_session_id), + Ok(outcome) => { + debug_assert_eq!( + outcome.used_cpu_fallback, + outcome.primary_recovery.is_some() ); - Ok(r) + *foundry_primary_recovery_for_transcribe.lock() = + outcome.primary_recovery; + Ok(outcome.raw) } Err(e) => { // 用户取消现在由外层 select! 统一处理(drop 掉本 future 中断在途转写), @@ -3616,6 +3628,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { schedule_foundry_local_asr_release( inner, AsrReleaseSession::Dictation(current_session_id), + None, ); let retryable = !crate::asr::local::foundry_runtime::is_terminal_foundry_fallback_error(&e); if !retryable { @@ -3778,6 +3791,15 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。 if inner.state.lock().cancelled { log::info!("[coord] cancel detected after ASR — discarding transcript"); + cancel_active_asr(asr_for_cancel); + #[cfg(target_os = "windows")] + if is_foundry_local { + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + None, + ); + } restore_prepared_windows_ime_session(inner, current_session_id); // PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。 // cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让 @@ -3787,6 +3809,15 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { return Ok(()); } + #[cfg(target_os = "windows")] + if is_foundry_local && transcribe_outcome.is_ok() { + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + foundry_primary_recovery.lock().take(), + ); + } + // ASR 失败/超时:先自动静默重试(从刚归档的音频重转,应对网络/服务端瞬时抖动)。上面的 // cancel 检查已先行——用户主动取消的会话不会走到这里触发重试。重试拿回文本就当作正常转写 // 继续走润色/插入;彻底失败才 fail_dictation 保留录音 + 报错(音频仍在,可去历史手动重转)。 diff --git a/openless-all/app/src-tauri/src/coordinator/qa_session.rs b/openless-all/app/src-tauri/src/coordinator/qa_session.rs index 13f33b763..d594842e5 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa_session.rs @@ -552,23 +552,46 @@ pub(super) async fn transcribe_overlay_dictation_asr( let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; let timeout_duration = windows_local_asr_transcribe_timeout(audio_secs); let notices = foundry_dictation_fallback_notice_callback(_inner, _current_session_id); - match local - .transcribe_with_fallback_notice(timeout_duration, notices) - .await - { - Ok(raw) => { - schedule_foundry_local_asr_release( - _inner, - AsrReleaseSession::Dictation(_current_session_id), - ); - Ok(raw) - } - Err(error) => { + tokio::select! { + result = local.transcribe_with_fallback_notice(timeout_duration, notices) => match result { + Ok(outcome) => { + debug_assert_eq!( + outcome.used_cpu_fallback, + outcome.primary_recovery.is_some() + ); + if _inner.state.lock().cancelled { + local.cancel(); + schedule_foundry_local_asr_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + None, + ); + return OverlayDictationTranscribeOutcome::Cancelled; + } + schedule_foundry_local_asr_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + outcome.primary_recovery, + ); + Ok(outcome.raw) + } + Err(error) => { + schedule_foundry_local_asr_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + None, + ); + Err(error.to_string()) + } + }, + _ = wait_for_overlay_dictation_cancel(_inner, _current_session_id) => { + local.cancel(); schedule_foundry_local_asr_release( _inner, AsrReleaseSession::Dictation(_current_session_id), + None, ); - Err(error.to_string()) + return OverlayDictationTranscribeOutcome::Cancelled; } } } @@ -1340,16 +1363,36 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { timeout_duration.as_secs() ); let notices = foundry_qa_fallback_notice_callback(inner, session_id); - match local - .transcribe_with_fallback_notice(timeout_duration, notices) - .await - { - Ok(r) => { - schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id)); - r + tokio::select! { + result = local.transcribe_with_fallback_notice(timeout_duration, notices) => match result { + Ok(outcome) => { + debug_assert_eq!( + outcome.used_cpu_fallback, + outcome.primary_recovery.is_some() + ); + if !qa_turn_can_continue(&inner.qa_state.lock(), session_id) { + local.cancel(); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Qa(qa_session_id), + None, + ); + finish_qa_idle_silently_if_current(inner, session_id); + return Ok(()); + } + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Qa(qa_session_id), + outcome.primary_recovery, + ); + outcome.raw } Err(e) => { - schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id)); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Qa(qa_session_id), + None, + ); if inner.qa_state.lock().cancelled { log::info!( "[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript" @@ -1367,6 +1410,17 @@ pub(super) async fn end_qa_session(inner: &Arc) -> Result<(), String> { ); return Err(e.to_string()); } + }, + _ = wait_for_qa_processing_cancel(inner, session_id) => { + local.cancel(); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Qa(qa_session_id), + None, + ); + finish_qa_idle_silently_if_current(inner, session_id); + return Ok(()); + } } } #[cfg(target_os = "windows")]