diff --git a/src-tauri/src/commands/live.rs b/src-tauri/src/commands/live.rs index c73816e..ee5ef04 100644 --- a/src-tauri/src/commands/live.rs +++ b/src-tauri/src/commands/live.rs @@ -300,6 +300,7 @@ impl LiveSessionRuntime { &self.dictionary_terms, &self.result_channel, &self.status_channel, + &self.stop_flag, )?; Ok(()) } @@ -859,6 +860,7 @@ fn maybe_dispatch_chunk( }) } +#[allow(clippy::too_many_arguments)] fn poll_inference( inflight: &mut Option, result_listener_lost: &mut bool, @@ -868,6 +870,7 @@ fn poll_inference( dictionary_terms: &[String], result_channel: &Channel, status_channel: &Channel, + stop_flag: &Arc, ) -> Result, String> { let Some(task) = inflight else { return Ok(None); @@ -918,6 +921,7 @@ fn poll_inference( result_channel, status_channel, result_listener_lost, + stop_flag, &result_message, ); remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs); @@ -968,6 +972,7 @@ fn emit_live_result( result_channel: &Channel, status_channel: &Channel, result_listener_lost: &mut bool, + stop_flag: &Arc, result_message: &LiveResultMessage, ) -> bool { if *result_listener_lost { @@ -982,10 +987,25 @@ fn emit_live_result( "[live] session {}: result listener unavailable on chunk {}: {}; continuing without live updates", result_message.session_id, result_message.chunk_id, err ); - let _ = status_channel.send(LiveStatusMessage::Warning { + // If the warning send also fails, the entire frontend channel + // pair is dead — almost certainly the user closed the app + // window without calling stop_live_transcription_session. + // Self-assert stop_flag so the inference worker drains and + // exits cleanly instead of polling every 10 ms forever, which + // otherwise would burn CPU + GPU memory and keep the WAV + // writer file handle open until the process dies. + let warn_send = status_channel.send(LiveStatusMessage::Warning { session_id: result_message.session_id, message: "Live preview disconnected; transcription will continue in the background until you stop the session.".into(), }); + if warn_send.is_err() { + eprintln!( + "[live] session {}: status channel also unavailable; \ + self-asserting stop_flag so the worker exits", + result_message.session_id + ); + stop_flag.store(true, Ordering::Relaxed); + } false } } @@ -1531,6 +1551,7 @@ mod tests { }; let mut recent_segments = Vec::new(); let mut result_listener_lost = false; + let stop_flag = Arc::new(AtomicBool::new(false)); let (tx1, rx1) = std::sync::mpsc::channel(); tx1.send(Ok(kon_transcription::TimedTranscript { @@ -1559,6 +1580,7 @@ mod tests { &[], &result_channel, &status_channel, + &stop_flag, ) .unwrap(); @@ -1600,12 +1622,21 @@ mod tests { &[], &result_channel, &status_channel, + &stop_flag, ) .unwrap(); assert_eq!(second, Some(true)); assert!(inflight.is_none()); assert_eq!(recent_segments.len(), 2); + // Status channel still alive in this scenario, so stop_flag must + // NOT have been auto-asserted — the worker keeps running so the + // user can still call stop_live_transcription_session and receive + // the Finished status. + assert!( + !stop_flag.load(Ordering::Relaxed), + "stop_flag should stay false when the status channel is alive" + ); assert_eq!( statuses.lock().unwrap().len(), warning_count_after_first, @@ -1613,6 +1644,47 @@ mod tests { ); } + #[test] + fn dead_result_and_status_channels_self_assert_stop_flag() { + // Both channels error on every send: the frontend has gone away + // entirely (e.g., the user closed the main window without a + // graceful stop). The worker must self-stop via stop_flag so it + // doesn't burn CPU + GPU for an indefinitely-long session that + // nobody is observing. + let result_channel: Channel = + Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage)); + let status_channel: Channel = + Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage)); + let stop_flag = Arc::new(AtomicBool::new(false)); + let mut result_listener_lost = false; + + let message = LiveResultMessage { + session_id: 99, + chunk_id: 1, + chunk_start_secs: 0.0, + duration: 0.5, + language: "en".into(), + inference_ms: 10, + segments: vec![], + raw_text: String::new(), + }; + + let delivered = emit_live_result( + &result_channel, + &status_channel, + &mut result_listener_lost, + &stop_flag, + &message, + ); + + assert!(!delivered, "send must report not delivered when result_channel errors"); + assert!(result_listener_lost, "result_listener_lost must be set"); + assert!( + stop_flag.load(Ordering::Relaxed), + "stop_flag must self-assert when both channels are dead so the worker exits" + ); + } + #[tokio::test] async fn concurrent_starts_allow_only_one_session_to_claim_the_slot() { let live_state = Arc::new(LiveTranscriptionState::default());