1use std::fs;
9use std::io::{Read, Write};
10use std::path::PathBuf;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use super::{cache_dir, cache_path, resolve_local, sha256_hex, ModelSpec, WeightFile};
14
15#[derive(Debug)]
17pub enum DownloadError {
18 NotHosted { model: String, file: String },
20 Http(String),
22 Io(std::io::Error),
24 ChecksumMismatch { file: String, expected: String, got: String },
26}
27
28impl std::fmt::Display for DownloadError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 Self::NotHosted { model, file } => write!(
32 f,
33 "weights for '{model}' are not hosted yet (file '{file}'). \
34 Supply it locally via $QSM_MODEL_DIR."
35 ),
36 Self::Http(msg) => write!(f, "download failed: {msg}"),
37 Self::Io(e) => write!(f, "io error: {e}"),
38 Self::ChecksumMismatch { file, expected, got } => write!(
39 f,
40 "checksum mismatch for '{file}': expected {expected}, got {got}"
41 ),
42 }
43 }
44}
45
46impl std::error::Error for DownloadError {}
47
48impl From<std::io::Error> for DownloadError {
49 fn from(e: std::io::Error) -> Self {
50 Self::Io(e)
51 }
52}
53
54pub fn ensure_file(model_id: &str, file: &WeightFile) -> Result<PathBuf, DownloadError> {
57 ensure_file_with_progress(model_id, file, &mut |_, _| {})
58}
59
60pub fn ensure_file_with_progress(
67 model_id: &str,
68 file: &WeightFile,
69 on_progress: &mut dyn FnMut(u64, u64),
70) -> Result<PathBuf, DownloadError> {
71 if let Some(path) = resolve_local(file) {
73 if file.sha256.is_empty() || checksum_ok(&path, file.sha256)? {
74 return Ok(path);
75 }
76 }
78
79 if file.url.is_empty() {
81 return Err(DownloadError::NotHosted {
82 model: model_id.to_string(),
83 file: file.name.to_string(),
84 });
85 }
86
87 let bytes = http_get(file.url, file.bytes, on_progress)?;
88
89 if !file.sha256.is_empty() {
91 let got = sha256_hex(&bytes);
92 if got != file.sha256 {
93 return Err(DownloadError::ChecksumMismatch {
94 file: file.name.to_string(),
95 expected: file.sha256.to_string(),
96 got,
97 });
98 }
99 }
100
101 write_cached(file, &bytes)
103}
104
105fn write_cached(file: &WeightFile, bytes: &[u8]) -> Result<PathBuf, DownloadError> {
116 let path = cache_path(file);
117 write_atomically(&cache_dir(), &path, file.name, bytes)?;
118 Ok(path)
119}
120
121fn write_atomically(dir: &std::path::Path, final_path: &std::path::Path, name: &str, bytes: &[u8]) -> Result<(), DownloadError> {
122 static WRITER: AtomicU64 = AtomicU64::new(0);
123 fs::create_dir_all(dir)?;
124 let tmp = dir.join(format!(
125 "{}.{}.{}.part",
126 name,
127 std::process::id(),
128 WRITER.fetch_add(1, Ordering::Relaxed)
129 ));
130 let written = (|| {
131 let mut f = fs::File::create(&tmp)?;
132 f.write_all(bytes)?;
133 f.sync_all()?;
134 fs::rename(&tmp, final_path)
135 })();
136 if let Err(e) = written {
137 let _ = fs::remove_file(&tmp);
138 return Err(e.into());
139 }
140 Ok(())
141}
142
143
144pub fn ensure_model(spec: &ModelSpec) -> Result<Vec<PathBuf>, DownloadError> {
147 spec.files.iter().map(|f| ensure_file(spec.id, f)).collect()
148}
149
150fn checksum_ok(path: &PathBuf, expected: &str) -> Result<bool, DownloadError> {
151 let bytes = fs::read(path)?;
152 Ok(sha256_hex(&bytes) == expected)
153}
154
155const MAX_ATTEMPTS: u32 = 5;
157
158fn http_get(
169 url: &str,
170 expected_total: u64,
171 on_progress: &mut dyn FnMut(u64, u64),
172) -> Result<Vec<u8>, DownloadError> {
173 let mut attempt = 0;
174 loop {
175 attempt += 1;
176 match http_get_once(url, expected_total, on_progress) {
177 Ok(bytes) => return Ok(bytes),
178 Err((msg, retryable)) => {
179 if !retryable || attempt >= MAX_ATTEMPTS {
180 return Err(DownloadError::Http(format!("{msg} (after {attempt} attempt(s))")));
181 }
182 let base = 500u64 << (attempt - 1);
184 let jitter = (url.bytes().map(u64::from).sum::<u64>() % 500) + u64::from(attempt) * 37;
185 std::thread::sleep(std::time::Duration::from_millis(base + jitter));
186 }
187 }
188 }
189}
190
191fn http_get_once(
193 url: &str,
194 expected_total: u64,
195 on_progress: &mut dyn FnMut(u64, u64),
196) -> Result<Vec<u8>, (String, bool)> {
197 match ureq::get(url).call() {
198 Ok(resp) => {
199 let total = if expected_total > 0 {
200 expected_total
201 } else {
202 resp.header("Content-Length").and_then(|s| s.parse().ok()).unwrap_or(0)
203 };
204 let mut reader = resp.into_reader();
205 let mut bytes: Vec<u8> = Vec::with_capacity(total as usize);
206 let mut buf = [0u8; 64 * 1024];
207 on_progress(0, total);
208 loop {
209 match reader.read(&mut buf) {
210 Ok(0) => break,
211 Ok(n) => {
212 bytes.extend_from_slice(&buf[..n]);
213 let done = bytes.len() as u64;
214 on_progress(done, total.max(done));
215 }
216 Err(e) => return Err((e.to_string(), true)),
218 }
219 }
220 Ok(bytes)
221 }
222 Err(ureq::Error::Status(code, _)) => {
224 let retryable = matches!(code, 403 | 408 | 425 | 429 | 500 | 502 | 503 | 504);
225 Err((format!("status code {code}"), retryable))
226 }
227 Err(e @ ureq::Error::Transport(_)) => Err((e.to_string(), true)),
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
239 fn concurrent_writers_leave_a_complete_file() {
240 let dir = std::env::temp_dir().join(format!("qsm-cache-race-{}", std::process::id()));
241 let _ = fs::remove_dir_all(&dir);
242 let final_path = dir.join("model.onnx");
243 let payload: Vec<u8> = (0..4_000_000u32).map(|i| (i % 251) as u8).collect();
244 std::thread::scope(|s| {
245 for _ in 0..8 {
246 s.spawn(|| {
247 for _ in 0..5 {
248 write_atomically(&dir, &final_path, "model.onnx", &payload).unwrap();
249 assert_eq!(fs::read(&final_path).unwrap(), payload);
250 }
251 });
252 }
253 });
254 let leftovers: Vec<_> = fs::read_dir(&dir).unwrap().filter_map(|e| e.ok())
255 .filter(|e| e.file_name().to_string_lossy().ends_with(".part")).collect();
256 assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
257 fs::remove_dir_all(&dir).unwrap();
258 }
259}