diff --git a/src-tauri/src/commands/live.rs b/src-tauri/src/commands/live.rs index 040ea6e..f3408ec 100644 --- a/src-tauri/src/commands/live.rs +++ b/src-tauri/src/commands/live.rs @@ -31,6 +31,25 @@ const CHUNK_SAMPLES: usize = 32_000; // 2s at 16kHz const OVERLAP_SAMPLES: usize = 4_000; // 0.25s at 16kHz const FINAL_CHUNK_MIN_SAMPLES: usize = 4_000; // 0.25s const MAX_PENDING_SAMPLES: usize = CHUNK_SAMPLES; + +/// Headroom multiplier for `drain_inference`'s timeout, derived from the +/// in-flight chunk's `duration_secs`. F3's original derivation used the +/// constant CHUNK_SAMPLES and capped at ~6s, which broke for the +/// realistic case of a slow CPU + large-v3 model running 3-5x realtime +/// on a chunk that happens to be SHORTER than CHUNK_SAMPLES — the +/// timeout would fire before the legitimate inference finished, treating +/// healthy work as a wedge. 5x realtime is the documented upper bound +/// for the slowest supported configuration (whisper.cpp large-v3, CPU +/// only, low-end laptop). Anything beyond that is a genuine wedge worth +/// aborting. +const REALTIME_SAFETY_MULTIPLIER: u32 = 5; + +/// Floor on the drain timeout. Even a sub-second tail chunk needs more +/// than its duration's worth of wall-clock to clear the decoder (model +/// load amortisation, OS scheduling jitter, the abort callback's own +/// poll cadence). 2s is conservative enough to never fire spuriously on +/// healthy backends and short enough to keep the lifecycle responsive. +const DRAIN_TIMEOUT_FLOOR: Duration = Duration::from_secs(2); const SPEECH_FRAME_SAMPLES: usize = 800; // 50ms const MIN_SPEECH_FRAMES: usize = 1; // any plausible speech-like frame const SILENCE_RMS_THRESHOLD: f32 = 0.001; @@ -521,15 +540,22 @@ impl LiveSessionRuntime { // abort callback — on expiry we set the flag and drop our // receiver, so the worker either honours the abort or exits // silently when it eventually tries to send its result. - let chunk_duration_ms = (CHUNK_SAMPLES as u64 * 1000) / WHISPER_SAMPLE_RATE as u64; - let drain_timeout_ms = (chunk_duration_ms.saturating_mul(3)).max(2_000); + // + // Budget is derived from the IN-FLIGHT task's own duration_secs + // rather than the constant CHUNK_SAMPLES so a short tail chunk + // running through a slow CPU/model combination (large-v3 at + // 3-5x realtime is the documented upper bound) isn't aborted + // mid-flight while it's still doing legitimate work. The + // multiplier + floor gives us safety on both ends. + let drain_timeout = drain_timeout_for_inflight(self.state.inflight.as_ref()); + let drain_timeout_ms = drain_timeout.as_millis() as u64; let drain_started = Instant::now(); while self.state.inflight.is_some() { self.poll_inference()?; if self.state.inflight.is_none() { break; } - if drain_started.elapsed() >= Duration::from_millis(drain_timeout_ms) { + if drain_started.elapsed() >= drain_timeout { // SAFETY: a real test of this path requires a wedged // whisper-rs, which is hard to fixture. We rely on the // abort_callback wiring plus cargo build + manual smoke @@ -542,6 +568,7 @@ impl LiveSessionRuntime { session_id = self.session_id, chunk_id = task.chunk_id, timeout_ms = drain_timeout_ms, + chunk_duration_secs = task.duration_secs, inflight_age_ms = task.dispatched_at.elapsed().as_millis() as u64, "drained inflight inference after timeout" ); @@ -607,6 +634,31 @@ impl Drop for InferenceTask { } } +/// Compute the drain-inference timeout for the in-flight task. Derived +/// from `task.duration_secs * REALTIME_SAFETY_MULTIPLIER` with a floor +/// at `DRAIN_TIMEOUT_FLOOR` so even a sub-second tail chunk gets enough +/// wall-clock to clear the decoder before we treat it as a wedge. +/// +/// `None` means there's nothing in flight — return the floor purely so +/// the drain loop has a well-defined fallback; in practice the outer +/// `while self.state.inflight.is_some()` short-circuits before the +/// timeout is consulted. +fn drain_timeout_for_inflight(task: Option<&InferenceTask>) -> Duration { + let Some(task) = task else { + return DRAIN_TIMEOUT_FLOOR; + }; + // Treat non-finite or non-positive durations as "use the floor" — + // we never want a NaN or negative to silently produce a tiny or + // overflowing budget. + let duration_secs = task.duration_secs; + if !duration_secs.is_finite() || duration_secs <= 0.0 { + return DRAIN_TIMEOUT_FLOOR; + } + let scaled_secs = duration_secs * REALTIME_SAFETY_MULTIPLIER as f64; + let scaled = Duration::from_secs_f64(scaled_secs); + scaled.max(DRAIN_TIMEOUT_FLOOR) +} + #[derive(Debug, Clone)] struct RecentTranscriptSegment { start_secs: f64, @@ -1932,6 +1984,87 @@ mod tests { assert_eq!(start.await.unwrap().unwrap(), 8); } + #[test] + fn drain_timeout_scales_with_inflight_chunk_duration_secs() { + // Time-bomb-1 regression: the drain timeout used to be a + // constant derived from CHUNK_SAMPLES (capped at ~6s). On slow + // CPU + large-v3 model (3-5x realtime), a 4-second chunk could + // legitimately need ~20s to finish; the old cap aborted + // healthy work mid-flight. The new derivation pulls duration + // from the in-flight task itself. + let task = InferenceTask { + chunk_id: 1, + chunk_start_sample: 0, + trim_before_secs: 0.0, + duration_secs: 4.0, + rx: std::sync::mpsc::channel().1, + abort_flag: Arc::new(AtomicBool::new(false)), + dispatched_at: Instant::now(), + }; + let timeout = drain_timeout_for_inflight(Some(&task)); + // 4.0s * 5x safety = 20s. Old constant-derived budget would + // have been ~6s, aborting at chunk_duration * 1.5 — well short + // of the 20s a 5x-realtime backend genuinely needs. + assert_eq!( + timeout, + Duration::from_secs(20), + "5x realtime headroom must be the actual budget" + ); + } + + #[test] + fn drain_timeout_honours_floor_for_short_chunks() { + // A sub-second tail chunk still needs more than its raw + // duration to clear the decoder (model load amortisation, OS + // scheduling, abort poll cadence). The floor guards against + // the timeout firing on legitimate short-chunk inference. + let task = InferenceTask { + chunk_id: 1, + chunk_start_sample: 0, + trim_before_secs: 0.0, + duration_secs: 0.3, // 0.3s * 5x = 1.5s, below the 2s floor + rx: std::sync::mpsc::channel().1, + abort_flag: Arc::new(AtomicBool::new(false)), + dispatched_at: Instant::now(), + }; + let timeout = drain_timeout_for_inflight(Some(&task)); + assert_eq!( + timeout, DRAIN_TIMEOUT_FLOOR, + "tiny chunks must still get the floor's worth of budget" + ); + } + + #[test] + fn drain_timeout_uses_floor_when_no_inflight_task() { + // Defensive: no-inflight is in practice short-circuited by the + // outer `while self.state.inflight.is_some()` loop, but the + // helper must still produce a well-defined non-zero value. + assert_eq!(drain_timeout_for_inflight(None), DRAIN_TIMEOUT_FLOOR); + } + + #[test] + fn drain_timeout_rejects_non_finite_duration() { + // Defensive against NaN / negative durations slipping in from + // a malformed task. We must not silently produce a tiny or + // overflowing budget — fall back to the floor. + for bad_duration in [f64::NAN, f64::INFINITY, -1.0, 0.0] { + let task = InferenceTask { + chunk_id: 1, + chunk_start_sample: 0, + trim_before_secs: 0.0, + duration_secs: bad_duration, + rx: std::sync::mpsc::channel().1, + abort_flag: Arc::new(AtomicBool::new(false)), + dispatched_at: Instant::now(), + }; + assert_eq!( + drain_timeout_for_inflight(Some(&task)), + DRAIN_TIMEOUT_FLOOR, + "non-finite duration {bad_duration:?} must fall back to the floor" + ); + } + } + #[test] fn dropping_inference_task_sets_abort_flag() { // Race-A regression: dropping an InferenceTask (via `?`