From c7cf3c19e713ec31556c5db75f0835e4ad07b8d4 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sun, 26 Jul 2026 23:19:21 +0900 Subject: [PATCH] fix(captions): resume model downloads instead of restarting from zero A dropped connection anywhere in the Parakeet download failed the whole operation and deleted the staging directory, so a user on an unreliable link could never finish: every attempt restarted at 0 bytes and had to survive ~650 MB in one unbroken transfer. Each part now tracks how much it has written and retries with a Range header, up to 5 attempts, with exponential backoff. Only mid-transfer drops are retried -- an attempt that received nothing (404, DNS, refused) returns immediately rather than burning five attempts on a hard failure. Two cases the naive version gets wrong: - A server that ignores Range answers 200 with the whole body. Appending that onto the prefix already on disk silently corrupts the file, so the response status is checked and the part rewound when it is not 206. - One entry in PARAKEET_TDT_FULL_MODEL_FILES is split across two URLs appended to the same file, so the rewind target is this part's start offset (captured with stream_position) rather than 0. Rewinding to 0 would discard the previous part. Verified by extracting the loop into a standalone binary and running it against a local server that drops connections mid-body: drops 2x, honours Range -> 400000/400000 bytes, byte-identical drops then ignores Range -> rewinds, byte-identical, no duplicate prefix 404 / connection refused -> fails in 0.018s, no retries always drops -> gives up after 5 attempts 2-part file, part 2 drops -> 'restarting part from 200000', both parts intact Relates to #1791 (that reporter's 30s timeout is already fixed; this is the remaining failure mode on a flaky connection). --- apps/desktop/src-tauri/src/captions.rs | 148 +++++++++++++++++++------ 1 file changed, 117 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index 53cd4abdc44..a1c908bcfb5 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -8,18 +8,20 @@ use ffmpeg::{ use futures::StreamExt; #[cfg(not(all(target_os = "macos", target_arch = "x86_64")))] use parakeet_rs::{ParakeetTDT, TimestampMode, Transcriber}; +use reqwest::{StatusCode, header::RANGE}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; use std::fs::File; use std::io::Read; +use std::io::SeekFrom; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tauri::{AppHandle, Manager}; use tauri_specta::Event; use tempfile::tempdir; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncSeekExt, AsyncWriteExt}; use tokio::sync::{Mutex, Notify}; use tracing::instrument; use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; @@ -2246,6 +2248,11 @@ pub async fn delete_whisper_model(app: AppHandle, model_path: String) -> Result< const MODEL_DOWNLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60 * 60); +/// How many times a single model part is re-attempted after the transfer drops +/// partway through. Each attempt resumes from what is already on disk, so the +/// cost of a retry is the remaining bytes rather than the whole file. +const MODEL_DOWNLOAD_MAX_ATTEMPTS: u32 = 5; + const PARAKEET_TDT_INT8_MODEL_FILES: &[(&str, &[&str])] = &[ ( "encoder-model.int8.onnx", @@ -2496,43 +2503,122 @@ async fn download_parakeet_model_to_dir( for url in *urls { tracing::info!("Downloading {filename} part from {url}"); - let response = http_client - .get(*url) - .timeout(MODEL_DOWNLOAD_REQUEST_TIMEOUT) - .send() + // Where this part begins in the file. Some entries are split + // across several URLs appended in order (see + // PARAKEET_TDT_FULL_MODEL_FILES), so a restart must rewind to + // the start of *this* part, not to the start of the file. + let part_start = file + .stream_position() .await - .map_err(|e| format!("Failed to download {filename}: {e}"))?; + .map_err(|e| format!("Failed to read position for {filename}: {e}"))?; - if !response.status().is_success() { - return Err(format!( - "Failed to download {filename}: HTTP {}", - response.status() - )); - } + // Bytes of *this part* already written. A dropped connection + // resumes from here with a Range request instead of discarding + // the staging directory and starting the whole model again. + let mut part_downloaded: u64 = 0; + let mut attempt: u32 = 0; - let mut stream = response.bytes_stream(); - while let Some(chunk_result) = stream.next().await { - let chunk = - chunk_result.map_err(|e| format!("Download error for {filename}: {e}"))?; - file.write_all(&chunk) - .await - .map_err(|e| format!("Write error for {filename}: {e}"))?; + loop { + attempt += 1; - downloaded_total = downloaded_total.saturating_add(chunk.len() as u64); + let mut request = http_client + .get(*url) + .timeout(MODEL_DOWNLOAD_REQUEST_TIMEOUT); + if part_downloaded > 0 { + request = request.header(RANGE, format!("bytes={part_downloaded}-")); + } - let progress = if total_size > 0 { - (downloaded_total as f64 / total_size as f64) * 100.0 - } else { - ((idx as f64 + 0.5) / model_files.len() as f64) * 100.0 - }; + let attempt_result = async { + let response = request + .send() + .await + .map_err(|e| format!("Failed to download {filename}: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to download {filename}: HTTP {}", + response.status() + )); + } - set_model_download_progress( - app, - download_key, - progress, - format!("Downloading {filename}: {progress:.0}%"), - ) + // A server that ignores Range answers 200 with the whole + // body. Rewind so the bytes line up instead of appending + // a second copy onto the first attempt's prefix. + if part_downloaded > 0 && response.status() != StatusCode::PARTIAL_CONTENT { + tracing::warn!( + "Range request for {filename} was not honoured; restarting part" + ); + file.set_len(part_start) + .await + .map_err(|e| format!("Failed to reset {filename}: {e}"))?; + file.seek(SeekFrom::Start(part_start)) + .await + .map_err(|e| format!("Failed to reset {filename}: {e}"))?; + downloaded_total = downloaded_total.saturating_sub(part_downloaded); + part_downloaded = 0; + } + + let mut stream = response.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result + .map_err(|e| format!("Download error for {filename}: {e}"))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("Write error for {filename}: {e}"))?; + + part_downloaded = part_downloaded.saturating_add(chunk.len() as u64); + downloaded_total = downloaded_total.saturating_add(chunk.len() as u64); + + let progress = if total_size > 0 { + (downloaded_total as f64 / total_size as f64) * 100.0 + } else { + ((idx as f64 + 0.5) / model_files.len() as f64) * 100.0 + }; + + set_model_download_progress( + app, + download_key, + progress, + format!("Downloading {filename}: {progress:.0}%"), + ) + .await; + } + + Ok(()) + } .await; + + match attempt_result { + Ok(()) => break, + Err(e) => { + // Only a mid-transfer drop is worth resuming. Give up + // immediately when nothing new arrived, otherwise a + // hard failure (404, DNS) would burn every attempt. + let made_progress = part_downloaded > 0; + if attempt >= MODEL_DOWNLOAD_MAX_ATTEMPTS || !made_progress { + return Err(e); + } + + tracing::warn!( + "{e}; resuming {filename} from {part_downloaded} bytes \ + (attempt {attempt}/{MODEL_DOWNLOAD_MAX_ATTEMPTS})" + ); + + set_model_download_progress( + app, + download_key, + if total_size > 0 { + (downloaded_total as f64 / total_size as f64) * 100.0 + } else { + ((idx as f64 + 0.5) / model_files.len() as f64) * 100.0 + }, + format!("Connection lost, resuming {filename}..."), + ) + .await; + + tokio::time::sleep(Duration::from_secs(1 << (attempt - 1))).await; + } + } } }