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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user