fix(live): self-stop worker when both result and status channels are dead

`emit_live_result` already detected a lost result_channel listener: it
sent a one-shot status warning and from then on short-circuited future
result sends. But if the status_channel listener was also gone — which
is what happens when the user closes the main window without calling
stop_live_transcription_session — the worker kept polling inflight
inference every 10 ms forever, holding a model loaded on the GPU and
keeping the WAV writer file handle open until the process exited.

When the warning send to status_channel also returns Err, the entire
frontend channel pair is dead. Self-assert stop_flag from inside
emit_live_result so the worker drains and exits cleanly. Existing user-
initiated stop semantics are unchanged.

- Threaded `stop_flag: &Arc<AtomicBool>` through `emit_live_result` and
  the free `poll_inference` (instance method already had access via
  `self.stop_flag`).
- Existing `result_listener_loss_is_warned_once_*` test updated to pass
  a stop_flag and assert it stays false when only result_channel fails.
- New test `dead_result_and_status_channels_self_assert_stop_flag` proves
  the self-stop fires when both channels Err.

(src-tauri doesn't build in the audit sandbox — needs webkit2gtk; CI
cross-platform compiles it.)

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
This commit is contained in:
Claude
2026-04-25 09:38:16 +00:00
parent d5d751c9ad
commit 73f8c45f86

View File

@@ -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<InferenceTask>,
result_listener_lost: &mut bool,
@@ -868,6 +870,7 @@ fn poll_inference(
dictionary_terms: &[String],
result_channel: &Channel<LiveResultMessage>,
status_channel: &Channel<LiveStatusMessage>,
stop_flag: &Arc<AtomicBool>,
) -> Result<Option<bool>, 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<LiveResultMessage>,
status_channel: &Channel<LiveStatusMessage>,
result_listener_lost: &mut bool,
stop_flag: &Arc<AtomicBool>,
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<LiveResultMessage> =
Channel::new(|_| Err(tauri::Error::FailedToReceiveMessage));
let status_channel: Channel<LiveStatusMessage> =
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());