diff --git a/crates/transcription/src/model_manager.rs b/crates/transcription/src/model_manager.rs index 4c59fb9..d6af3bd 100644 --- a/crates/transcription/src/model_manager.rs +++ b/crates/transcription/src/model_manager.rs @@ -350,6 +350,30 @@ mod tests { addr } + /// A minimal HTTP server that always responds with 200 + the full + /// body, even when the request carries a `Range` header. Mirrors a + /// mirror / proxy that strips Range support, exercising the + /// ResumeUnsupported branch in `download_file`. + async fn spawn_no_range_server(content: Vec) -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 2048]; + let _ = socket.read(&mut buf).await.unwrap(); + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Length: {}\r\n\r\n", + content.len(), + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.write_all(&content).await.unwrap(); + }); + + addr + } + /// ModelFile stores `&'static str` fields, so we leak the strings /// once per test — tests are one-shot, so the cost is noise. fn leak(s: String) -> &'static str { @@ -387,6 +411,42 @@ mod tests { assert_eq!(sha256_of_file(&dest).unwrap(), expected_sha); } + #[tokio::test] + async fn download_file_restarts_when_server_ignores_range() { + // Covers the ResumeUnsupported branch documented in `download_file`: + // when a partial `.part` file exists and the server returns 200 + // (full body) to our Range request, we must discard the stale + // partial bytes and write the fresh body from offset zero rather + // than appending on top. + let body = b"fresh transcription payload that replaces any stale partial".to_vec(); + let addr = spawn_no_range_server(body.clone()).await; + + let dir = tempdir().unwrap(); + let dest = dir.path().join("fixture.bin"); + let part = dest.with_extension("bin.part"); + // Pretend a previous attempt downloaded 12 bytes of something + // entirely unrelated. If the client naively appended the 200 + // body, the final file would start with these bytes. + std::fs::write(&part, b"STALE_BYTES1").unwrap(); + + let file = ModelFile { + filename: leak(dest.file_name().unwrap().to_string_lossy().into_owned()), + url: leak(format!("http://{addr}/fixture.bin")), + size: kon_core::types::Megabytes(0), + sha256: None, + }; + let id = ModelId::new("test-fixture"); + + download_file(&file, &dest, &id, &|_| ()).await.unwrap(); + + let bytes = std::fs::read(&dest).unwrap(); + assert_eq!( + bytes, body, + "server returned 200 to Range — downloader must discard stale .part and rewrite from scratch" + ); + assert!(!part.exists(), ".part → dest rename must run after restart"); + } + #[tokio::test] async fn download_file_fails_on_sha_mismatch_and_cleans_part_file() { let body = b"speech-to-text fixture body".to_vec();