agent: code-atomiser-fix — atomic model download + manifest (Rev-1, Rev-5)
Two reversibility kills in the model-download path both followed the
same pattern: SHA mismatch on an existing file triggered
`remove_file(&dest)` BEFORE the network round-trip. A network blip /
power loss between the unlink and the eventual `rename(.part, dest)`
left users with neither the old (corrupt-but-readable) model nor a
fresh one — 1.5-20 GB redownload from scratch with no fallback.
Rev-1 (crates/llm/src/model_manager.rs):
- Extract the existing-file decision into `download_to`, drop the
pre-emptive unlink. `download_impl` already writes via `.part`
and atomically renames; the rename overwrites on success and
leaves dest untouched on failure.
- Regression test `download_failure_preserves_existing_file` plants
a sentinel "OLD" file at dest, points at a 500-returning server,
and asserts dest still exists with original contents after the
failed download.
Rev-5 (crates/transcription/src/model_manager.rs):
- Drop the pre-emptive unlink in the outer `download()` SHA-mismatch
branch. Same atomic rename via `download_file`.
- Make `write_verified_manifest` atomic: write to `.tmp`, fsync,
rename. Previous direct `fs::write` truncates-then-writes, so a
crash mid-write left an empty/torn manifest and triggered a full
GB-sized redownload on next boot.
- `download_file_failure_preserves_existing_dest_file` and
`manifest_write_is_atomic` regression tests added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -276,16 +276,44 @@ where
|
||||
let _reservation = DownloadReservation::acquire(id)?;
|
||||
let dest = model_path(id);
|
||||
tokio::fs::create_dir_all(model_dir()).await?;
|
||||
download_to(id.hf_url(), id.sha256(), &dest, on_progress).await
|
||||
}
|
||||
|
||||
/// Inner driver split out of `download_model` so the
|
||||
/// existing-file / SHA-mismatch / new-download decision can be
|
||||
/// exercised by tests without hitting the hardcoded Hugging Face URLs
|
||||
/// on `LlmModelId`. Behaviour:
|
||||
/// 1. If `dest` already exists and its SHA matches — done, no network.
|
||||
/// 2. If `dest` exists but the SHA mismatches — DO NOT delete; fall
|
||||
/// through to `download_impl` which writes via `.part` and renames
|
||||
/// atomically on success. Rev-1 reversibility kill (atomiser
|
||||
/// 2026-05-12): the previous implementation called
|
||||
/// `remove_file(&dest)` here before the network round-trip. A
|
||||
/// network blip / power loss / disk-full between the unlink and
|
||||
/// the eventual `rename` left users with neither the old
|
||||
/// (corrupted-but-readable) model nor the new one — a 1.5–20 GB
|
||||
/// redownload from scratch with no fallback.
|
||||
/// 3. If `dest` doesn't exist — straight to `download_impl`.
|
||||
async fn download_to<F>(
|
||||
url: &str,
|
||||
expected_sha: &str,
|
||||
dest: &Path,
|
||||
on_progress: F,
|
||||
) -> Result<(), DownloadError>
|
||||
where
|
||||
F: FnMut(u64, u64) + Send + 'static,
|
||||
{
|
||||
if dest.exists() {
|
||||
let actual = sha256_file(&dest).await?;
|
||||
if actual == id.sha256() {
|
||||
let actual = sha256_file(dest).await?;
|
||||
if actual == expected_sha {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::fs::remove_file(&dest).await?;
|
||||
// SHA mismatch: do NOT unlink. `download_impl` writes to a
|
||||
// `.part` sibling and atomically renames over `dest` once the
|
||||
// new payload verifies. On failure the user keeps the old
|
||||
// file (even if "corrupt") rather than ending up with nothing.
|
||||
}
|
||||
|
||||
download_impl(id.hf_url(), id.sha256(), &dest, on_progress).await
|
||||
download_impl(url, expected_sha, dest, on_progress).await
|
||||
}
|
||||
|
||||
async fn sha256_file(path: &Path) -> Result<String, io::Error> {
|
||||
@@ -483,4 +511,64 @@ mod tests {
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
|
||||
/// Rev-1 regression (atomiser 2026-05-12). Before the fix the
|
||||
/// SHA-mismatch path in `download_model` deleted the existing
|
||||
/// file BEFORE the network call. A failing download then left
|
||||
/// the user with neither the old nor the new model.
|
||||
///
|
||||
/// We exercise `download_to` (the testable inner driver) with
|
||||
/// an existing sentinel file at `dest` whose SHA does NOT match
|
||||
/// the expected one, against a server that returns HTTP 500.
|
||||
/// The function must fail; the destination must still exist
|
||||
/// with its original contents.
|
||||
#[tokio::test]
|
||||
async fn download_failure_preserves_existing_file() {
|
||||
let server = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = server.local_addr().unwrap();
|
||||
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (mut socket, _) = server.accept().await.unwrap();
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let _ = socket.read(&mut buf).await.unwrap();
|
||||
let body = b"upstream blew up";
|
||||
let response = format!(
|
||||
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
socket.write_all(body).await.unwrap();
|
||||
});
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let dest = dir.path().join("fixture.gguf");
|
||||
// Sentinel "old model" file the user already had on disk.
|
||||
tokio::fs::write(&dest, b"OLD").await.unwrap();
|
||||
|
||||
// Expect-sha is deliberately something the OLD file does NOT
|
||||
// hash to, so the existing-file branch falls through to
|
||||
// download_impl (the exact case the atomiser flagged).
|
||||
let expected_sha = "0".repeat(64);
|
||||
|
||||
download_to(
|
||||
&format!("http://{addr}/fixture.gguf"),
|
||||
&expected_sha,
|
||||
&dest,
|
||||
|_, _| {},
|
||||
)
|
||||
.await
|
||||
.expect_err("500 response must fail the download");
|
||||
|
||||
assert!(
|
||||
dest.exists(),
|
||||
"download failure must leave the existing dest in place"
|
||||
);
|
||||
let preserved = tokio::fs::read(&dest).await.unwrap();
|
||||
assert_eq!(
|
||||
preserved, b"OLD",
|
||||
"existing file contents must be untouched on failed download"
|
||||
);
|
||||
|
||||
server_task.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user