fix: skip fetch when prior download is already complete on disk (#351)
* fix: skip fetch when prior download is already complete on disk A prior attempt that finished downloading but failed a post-download step (inflate / hash check / install) used to leave the partial file and sidecar in place. The next update would call compute_resume_offset, get back the file's full size, and send Range: bytes=N- against an N-byte resource — past the end of the file, yielding HTTP 416 forever. Replace compute_resume_offset with a read-only determine_download_start_state returning Fresh | Resume(u64) | Complete(u64). update_internal handles Complete by skipping download_to_path entirely and letting the install path validate the existing bytes; this also avoids re-downloading when the app is killed between download and install. Extract the post-download work into install_downloaded_patch so cleanup can run unconditionally once after it returns. Three previous cleanup sites collapse to one. If install rejects the bytes, the next attempt re-downloads from scratch. * refactor: pass output_path by value into install_downloaded_patch Avoids a redundant PathBuf clone on the success path. The caller already owns the PathBuf from download_dir.join(...), and PatchInfo wants an owned path, so threading ownership through is strictly cheaper than borrowing and cloning. * fix: record actual download size in sidecar to handle chunked transfers When the server uses chunked transfer encoding (no Content-Length header), dl_result.content_length is None. The old code recorded that None as expected_size, which meant a subsequent crash-before-install attempt would see expected_size: None + a full-size file, fall through to Resume(file_size), and re-create the HTTP 416 loop this PR fixes. Recording dl_result.total_bytes (the actual on-disk size) closes the chunked-encoding case. The two values are equal when Content-Length is present, so the Content-Length case is unchanged. Also annotate two pre-existing windows that this PR doesn't fully close, so they stay visible until the per-patch state machine refactor lands: - The microsecond gap between download_to_path returning and the second write_download_state succeeding can still leave expected_size: None. - cleanup_download_artifacts logs delete errors instead of propagating; a silently-failed delete could re-create the same 416 loop. Both are tracked in shorebirdtech/shorebird#3737.
This commit is contained in:
+349
-127
@@ -14,7 +14,9 @@ use crate::config::{set_config, with_config, UpdateConfig};
|
||||
use crate::download_state::{self, DownloadState};
|
||||
use crate::events::{EventType, PatchEvent};
|
||||
use crate::logging::init_logging;
|
||||
use crate::network::{download_to_path, patches_check_url, NetworkHooks, PatchCheckRequest};
|
||||
use crate::network::{
|
||||
download_to_path, patches_check_url, DownloadResult, NetworkHooks, PatchCheckRequest,
|
||||
};
|
||||
use crate::updater_lock::{with_updater_thread_lock, UpdaterLockState};
|
||||
use crate::yaml::YamlConfig;
|
||||
|
||||
@@ -431,8 +433,7 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul
|
||||
let download_dir = PathBuf::from(&config.download_dir);
|
||||
let download_path = download_dir.join(patch.number.to_string());
|
||||
|
||||
// Compute resume offset (checks sidecar for matching URL/patch/hash).
|
||||
let resume_from = compute_resume_offset(
|
||||
let start_state = determine_download_start_state(
|
||||
&download_path,
|
||||
&patch.download_url,
|
||||
patch.number,
|
||||
@@ -449,37 +450,101 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul
|
||||
// partial download for a patch that's since been replaced).
|
||||
clean_download_dir(&download_dir, patch.number);
|
||||
|
||||
// Write sidecar *before* downloading so we can resume on crash.
|
||||
let dl_state = DownloadState {
|
||||
url: patch.download_url.clone(),
|
||||
patch_number: patch.number,
|
||||
expected_size: None,
|
||||
expected_hash: patch.hash.clone(),
|
||||
let dl_result = match start_state {
|
||||
DownloadStartState::Complete(size) => {
|
||||
// Bytes from a prior attempt are already on disk and the sidecar
|
||||
// says they're the expected size. Skip the network request and
|
||||
// let the install path validate them — re-downloading would just
|
||||
// waste bandwidth in cases like the app being killed between the
|
||||
// download finishing and install starting.
|
||||
shorebird_info!(
|
||||
"Prior download already complete ({} bytes); skipping fetch.",
|
||||
size
|
||||
);
|
||||
DownloadResult {
|
||||
total_bytes: size,
|
||||
content_length: Some(size),
|
||||
}
|
||||
}
|
||||
DownloadStartState::Fresh | DownloadStartState::Resume(_) => {
|
||||
// Write sidecar *before* downloading so we can resume on crash.
|
||||
let dl_state = DownloadState {
|
||||
url: patch.download_url.clone(),
|
||||
patch_number: patch.number,
|
||||
expected_size: None,
|
||||
expected_hash: patch.hash.clone(),
|
||||
};
|
||||
download_state::write_download_state(&download_path, &dl_state)?;
|
||||
|
||||
let resume_from = match start_state {
|
||||
DownloadStartState::Resume(offset) => {
|
||||
shorebird_info!("Resuming download from byte {}", offset);
|
||||
offset
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// Consider supporting allowing the system to download for us (e.g. iOS).
|
||||
let dl_result = download_to_path(
|
||||
&config.network_hooks,
|
||||
&patch.download_url,
|
||||
&download_path,
|
||||
resume_from,
|
||||
)?;
|
||||
|
||||
// Record the actual on-disk size, not the server's Content-Length.
|
||||
// For chunked transfer encoding the server doesn't send
|
||||
// Content-Length, so dl_result.content_length would be None — and
|
||||
// a subsequent crash-before-install attempt would then see
|
||||
// expected_size: None plus a full-size file, fall through to
|
||||
// Resume(file_size), and re-create the same HTTP 416 this PR
|
||||
// fixes. Using total_bytes works for both chunked and
|
||||
// Content-Length responses (they're equal in the latter case).
|
||||
//
|
||||
// KNOWN GAP: if the process dies between download_to_path
|
||||
// returning above and this sidecar write succeeding, expected_size
|
||||
// stays None from the pre-download write and we hit the 416 loop
|
||||
// anyway. The window is microseconds; closing it requires
|
||||
// restructuring (e.g. download_to_path writing the sidecar
|
||||
// itself, or a single atomic per-patch state document) — tracked
|
||||
// in shorebirdtech/shorebird#3737.
|
||||
let dl_state = DownloadState {
|
||||
expected_size: Some(dl_result.total_bytes),
|
||||
..dl_state
|
||||
};
|
||||
download_state::write_download_state(&download_path, &dl_state)?;
|
||||
|
||||
dl_result
|
||||
}
|
||||
};
|
||||
download_state::write_download_state(&download_path, &dl_state)?;
|
||||
|
||||
// Consider supporting allowing the system to download for us (e.g. iOS).
|
||||
let dl_result = download_to_path(
|
||||
&config.network_hooks,
|
||||
&patch.download_url,
|
||||
&download_path,
|
||||
resume_from,
|
||||
)?;
|
||||
|
||||
// Update sidecar with the now-known total size.
|
||||
// content_length is already the total file size (from Content-Range for
|
||||
// 206, or Content-Length for 200).
|
||||
let dl_state = DownloadState {
|
||||
expected_size: dl_result.content_length,
|
||||
..dl_state
|
||||
};
|
||||
download_state::write_download_state(&download_path, &dl_state)?;
|
||||
let output_path = download_dir.join(format!("{}.full", patch.number));
|
||||
// Once the download bytes are on disk, they will either be consumed
|
||||
// (install success) or rejected (any failure). Either way the artifacts
|
||||
// have served their purpose — clean them up unconditionally so a stuck
|
||||
// partial file can't make a future Range request go past the end of the
|
||||
// file and yield HTTP 416.
|
||||
let result = install_downloaded_patch(&config, &patch, &dl_result, &download_path, output_path);
|
||||
cleanup_download_artifacts(&download_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// Consumes the bytes at `download_path`: validates the size, inflates them,
|
||||
/// hash-checks the result, and installs the patch. Returns `UpdateInstalled`
|
||||
/// on success.
|
||||
///
|
||||
/// Caller is responsible for cleaning up `download_path` artifacts after this
|
||||
/// returns, regardless of outcome.
|
||||
fn install_downloaded_patch(
|
||||
config: &UpdateConfig,
|
||||
patch: &crate::network::Patch,
|
||||
dl_result: &DownloadResult,
|
||||
download_path: &Path,
|
||||
output_path: PathBuf,
|
||||
) -> anyhow::Result<UpdateStatus> {
|
||||
// Validate download size if Content-Length was provided.
|
||||
if let Some(expected) = dl_state.expected_size {
|
||||
if let Some(expected) = dl_result.content_length {
|
||||
if dl_result.total_bytes != expected {
|
||||
// Corrupted — clean up so next attempt starts fresh.
|
||||
cleanup_download_artifacts(&download_path);
|
||||
bail!(
|
||||
"Download size mismatch: expected {} bytes, got {}",
|
||||
expected,
|
||||
@@ -488,9 +553,8 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul
|
||||
}
|
||||
}
|
||||
|
||||
let output_path = download_dir.join(format!("{}.full", patch.number));
|
||||
let patch_base_rs = patch_base(&config)?;
|
||||
inflate(&download_path, patch_base_rs, &output_path)?;
|
||||
let patch_base_rs = patch_base(config)?;
|
||||
inflate(download_path, patch_base_rs, &output_path)?;
|
||||
|
||||
// Check the hash before moving into place.
|
||||
check_hash(&output_path, &patch.hash).with_context(|| {
|
||||
@@ -516,15 +580,14 @@ fn update_internal(_: &UpdaterLockState, channel: Option<&str>) -> anyhow::Resul
|
||||
patch.number
|
||||
);
|
||||
|
||||
// Clean up download artifacts now that installation succeeded.
|
||||
cleanup_download_artifacts(&download_path);
|
||||
|
||||
let client_id = state.client_id();
|
||||
let config = config.clone();
|
||||
let patch_number = patch.number;
|
||||
std::thread::spawn(move || {
|
||||
let event = PatchEvent::new(
|
||||
&config,
|
||||
EventType::PatchDownload,
|
||||
patch.number,
|
||||
patch_number,
|
||||
client_id,
|
||||
None,
|
||||
);
|
||||
@@ -573,39 +636,61 @@ fn should_install_patch(patch_number: usize) -> Result<ShouldInstallPatchCheckRe
|
||||
Ok(ShouldInstallPatchCheckResult::PatchOkToInstall)
|
||||
}
|
||||
|
||||
/// Determines how many bytes of a prior partial download we can resume from.
|
||||
/// Returns 0 if we should start fresh.
|
||||
fn compute_resume_offset(
|
||||
/// What the prior attempt left on disk, and what to do about it.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum DownloadStartState {
|
||||
/// No usable prior artifacts — request the full file.
|
||||
Fresh,
|
||||
/// Partial file from an interrupted prior attempt. Send a Range header
|
||||
/// from this byte offset to ask the server for the remainder.
|
||||
Resume(u64),
|
||||
/// Prior attempt finished downloading but didn't finish installing
|
||||
/// (e.g. the process was killed between download and inflate, or
|
||||
/// inflate/install failed). Skip the network request and let the
|
||||
/// install path validate the bytes already on disk.
|
||||
Complete(u64),
|
||||
}
|
||||
|
||||
/// Inspects the sidecar and partial file to decide how the next download
|
||||
/// attempt should begin. Read-only — does not modify on-disk state.
|
||||
fn determine_download_start_state(
|
||||
download_path: &Path,
|
||||
url: &str,
|
||||
patch_number: usize,
|
||||
expected_hash: &str,
|
||||
) -> u64 {
|
||||
// Check for a sidecar file describing a prior download attempt.
|
||||
) -> DownloadStartState {
|
||||
let prior_state = match download_state::read_download_state(download_path) {
|
||||
Ok(Some(state)) => state,
|
||||
_ => return 0,
|
||||
_ => return DownloadStartState::Fresh,
|
||||
};
|
||||
|
||||
// Only resume if URL, patch number, and hash all match. The hash check
|
||||
// catches the case where a patch is deleted and re-added with the same
|
||||
// number — the URL might stay the same but the content differs.
|
||||
// The hash check catches the case where a patch is deleted and re-added
|
||||
// with the same number — the URL might stay the same but the content
|
||||
// differs.
|
||||
if prior_state.url != url
|
||||
|| prior_state.patch_number != patch_number
|
||||
|| prior_state.expected_hash != expected_hash
|
||||
{
|
||||
shorebird_info!("Download state mismatch, starting fresh.");
|
||||
return 0;
|
||||
return DownloadStartState::Fresh;
|
||||
}
|
||||
|
||||
// Check that the partial file exists and has some content.
|
||||
match std::fs::metadata(download_path) {
|
||||
Ok(meta) if meta.len() > 0 => {
|
||||
shorebird_info!("Resuming download from byte {}", meta.len());
|
||||
meta.len()
|
||||
let file_size = match std::fs::metadata(download_path) {
|
||||
Ok(meta) if meta.len() > 0 => meta.len(),
|
||||
_ => return DownloadStartState::Fresh,
|
||||
};
|
||||
|
||||
// If the prior attempt's bytes look complete, skip the network request.
|
||||
// The install path will validate them; if they're bad it will fail and
|
||||
// the unconditional cleanup at the end of `update_internal` will wipe
|
||||
// them so the next attempt re-downloads.
|
||||
if let Some(expected) = prior_state.expected_size {
|
||||
if file_size >= expected {
|
||||
return DownloadStartState::Complete(file_size);
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
|
||||
DownloadStartState::Resume(file_size)
|
||||
}
|
||||
|
||||
/// Removes everything in `download_dir` except files belonging to
|
||||
@@ -643,8 +728,15 @@ fn clean_download_dir(download_dir: &Path, current_patch_number: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the compressed download file and its sidecar after a successful
|
||||
/// install.
|
||||
/// Removes the compressed download file and its sidecar.
|
||||
///
|
||||
/// KNOWN GAP: delete errors are logged but not propagated. If a delete
|
||||
/// silently fails (e.g. transient filesystem error), the stale artifacts
|
||||
/// persist into the next cycle and could re-create the same 416 loop this
|
||||
/// PR fixes — `determine_download_start_state` would still see a sidecar
|
||||
/// with `expected_size` matching the file and try to resume against a
|
||||
/// resource that's no longer the right one. Disk delete failures are rare
|
||||
/// in practice; the proper fix lives in shorebirdtech/shorebird#3737.
|
||||
fn cleanup_download_artifacts(download_path: &Path) {
|
||||
if let Err(e) = download_state::delete_download_state(download_path) {
|
||||
shorebird_error!("Failed to delete download sidecar: {:?}", e);
|
||||
@@ -1729,55 +1821,65 @@ patch_verification: bogus_mode
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_resume_offset_no_sidecar() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
use super::DownloadStartState;
|
||||
|
||||
// No sidecar, no partial file → fresh download.
|
||||
assert_eq!(
|
||||
super::compute_resume_offset(&download_path, "http://example.com/patch", 1, "abc123"),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_resume_offset_matching_sidecar() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
// Write partial file.
|
||||
fs::write(&download_path, vec![0u8; 500]).unwrap();
|
||||
|
||||
// Write matching sidecar.
|
||||
fn write_state(download_path: &Path, expected_size: Option<u64>) {
|
||||
crate::download_state::write_download_state(
|
||||
&download_path,
|
||||
download_path,
|
||||
&crate::download_state::DownloadState {
|
||||
url: "http://example.com/patch".to_string(),
|
||||
patch_number: 1,
|
||||
expected_size: Some(1000),
|
||||
expected_size,
|
||||
expected_hash: "abc123".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_start_state_no_sidecar() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
// Should resume from 500 bytes.
|
||||
assert_eq!(
|
||||
super::compute_resume_offset(&download_path, "http://example.com/patch", 1, "abc123"),
|
||||
500
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/patch",
|
||||
1,
|
||||
"abc123"
|
||||
),
|
||||
DownloadStartState::Fresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_resume_offset_mismatched_url() {
|
||||
fn download_start_state_matching_sidecar_resumes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
fs::write(&download_path, vec![0u8; 500]).unwrap();
|
||||
write_state(&download_path, Some(1000));
|
||||
|
||||
assert_eq!(
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/patch",
|
||||
1,
|
||||
"abc123"
|
||||
),
|
||||
DownloadStartState::Resume(500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_start_state_mismatched_url_starts_fresh() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
fs::write(&download_path, vec![0u8; 500]).unwrap();
|
||||
crate::download_state::write_download_state(
|
||||
&download_path,
|
||||
&crate::download_state::DownloadState {
|
||||
@@ -1789,26 +1891,24 @@ patch_verification: bogus_mode
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Different URL → fresh download.
|
||||
assert_eq!(
|
||||
super::compute_resume_offset(
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/new-patch",
|
||||
1,
|
||||
"abc123"
|
||||
),
|
||||
0
|
||||
DownloadStartState::Fresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_resume_offset_mismatched_hash() {
|
||||
fn download_start_state_mismatched_hash_starts_fresh() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
fs::write(&download_path, vec![0u8; 500]).unwrap();
|
||||
|
||||
crate::download_state::write_download_state(
|
||||
&download_path,
|
||||
&crate::download_state::DownloadState {
|
||||
@@ -1820,85 +1920,107 @@ patch_verification: bogus_mode
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Same URL but different hash (patch was re-created) → fresh download.
|
||||
assert_eq!(
|
||||
super::compute_resume_offset(&download_path, "http://example.com/patch", 1, "hash_new"),
|
||||
0
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/patch",
|
||||
1,
|
||||
"hash_new"
|
||||
),
|
||||
DownloadStartState::Fresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_resume_offset_mismatched_patch_number() {
|
||||
fn download_start_state_mismatched_patch_number_starts_fresh() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
fs::write(&download_path, vec![0u8; 500]).unwrap();
|
||||
write_state(&download_path, None);
|
||||
|
||||
crate::download_state::write_download_state(
|
||||
&download_path,
|
||||
&crate::download_state::DownloadState {
|
||||
url: "http://example.com/patch".to_string(),
|
||||
patch_number: 1,
|
||||
expected_size: None,
|
||||
expected_hash: "abc123".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Same URL but different patch number → fresh download.
|
||||
assert_eq!(
|
||||
super::compute_resume_offset(&download_path, "http://example.com/patch", 2, "abc123"),
|
||||
0
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/patch",
|
||||
2,
|
||||
"abc123"
|
||||
),
|
||||
DownloadStartState::Fresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_resume_offset_corrupt_sidecar() {
|
||||
fn download_start_state_corrupt_sidecar_starts_fresh() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
fs::write(&download_path, vec![0u8; 500]).unwrap();
|
||||
|
||||
// Write garbage to the sidecar file.
|
||||
let sidecar = crate::download_state::sidecar_path(&download_path);
|
||||
fs::write(&sidecar, "not valid json").unwrap();
|
||||
|
||||
// Corrupt sidecar → fresh download.
|
||||
assert_eq!(
|
||||
super::compute_resume_offset(&download_path, "http://example.com/patch", 1, "abc123"),
|
||||
0
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/patch",
|
||||
1,
|
||||
"abc123"
|
||||
),
|
||||
DownloadStartState::Fresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_resume_offset_empty_file() {
|
||||
fn download_start_state_empty_file_starts_fresh() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
// Write empty file.
|
||||
fs::write(&download_path, []).unwrap();
|
||||
write_state(&download_path, None);
|
||||
|
||||
crate::download_state::write_download_state(
|
||||
&download_path,
|
||||
&crate::download_state::DownloadState {
|
||||
url: "http://example.com/patch".to_string(),
|
||||
patch_number: 1,
|
||||
expected_size: None,
|
||||
expected_hash: "abc123".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Empty file → fresh download.
|
||||
assert_eq!(
|
||||
super::compute_resume_offset(&download_path, "http://example.com/patch", 1, "abc123"),
|
||||
0
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/patch",
|
||||
1,
|
||||
"abc123"
|
||||
),
|
||||
DownloadStartState::Fresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_start_state_full_file_reports_complete() {
|
||||
// Reproduces the customer's stuck state: a prior attempt finished
|
||||
// downloading (file size == expected_size) but install failed (or the
|
||||
// app was killed before install). The function should report Complete
|
||||
// so the caller skips the network request and validates the bytes.
|
||||
// It must NOT delete anything — that's the caller's job after install.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let download_path = tmp.path().join("downloads/1");
|
||||
fs::create_dir_all(download_path.parent().unwrap()).unwrap();
|
||||
|
||||
fs::write(&download_path, vec![0u8; 1000]).unwrap();
|
||||
write_state(&download_path, Some(1000));
|
||||
|
||||
assert_eq!(
|
||||
super::determine_download_start_state(
|
||||
&download_path,
|
||||
"http://example.com/patch",
|
||||
1,
|
||||
"abc123"
|
||||
),
|
||||
DownloadStartState::Complete(1000)
|
||||
);
|
||||
|
||||
// determine_download_start_state must be read-only.
|
||||
assert!(download_path.exists());
|
||||
assert!(crate::download_state::sidecar_path(&download_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_download_artifacts_removes_file_and_sidecar() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -3624,6 +3746,106 @@ mod resume_edge_case_tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When the downloaded bytes are unusable (e.g. non-zstd garbage that
|
||||
/// fails inflate), the next attempt must not try to resume from them —
|
||||
/// otherwise we'd hit the same failure on every cycle. Verify the
|
||||
/// download artifacts are cleaned up after a post-download failure.
|
||||
#[serial]
|
||||
#[test]
|
||||
fn inflate_failure_cleans_up_download_artifacts() -> Result<()> {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
init_for_testing(&tmp_dir, None);
|
||||
|
||||
let base = "hello world";
|
||||
let apk_path = tmp_dir.path().join("base.apk");
|
||||
write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
|
||||
|
||||
setup_hooks_with_download(|_url, dest: &Path, _resume_from: u64| {
|
||||
// Write garbage that will fail zstd magic check during inflate.
|
||||
fs::write(dest, b"this is not zstd data and never will be xx")?;
|
||||
Ok(DownloadResult {
|
||||
total_bytes: 42,
|
||||
content_length: Some(42),
|
||||
})
|
||||
});
|
||||
|
||||
let result = crate::update(None);
|
||||
assert!(result.is_err());
|
||||
|
||||
let download_path = tmp_dir.path().join("downloads/1");
|
||||
let sidecar_path = crate::download_state::sidecar_path(&download_path);
|
||||
assert!(
|
||||
!download_path.exists(),
|
||||
"download file should be cleaned up after inflate failure"
|
||||
);
|
||||
assert!(
|
||||
!sidecar_path.exists(),
|
||||
"sidecar should be cleaned up after inflate failure"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Covers two scenarios that look identical on disk but used to manifest
|
||||
/// as the customer-reported 416 loop:
|
||||
///
|
||||
/// 1. The app was killed between the download finishing and install
|
||||
/// starting. The bytes are valid; we just need to install them.
|
||||
/// 2. A post-download step (inflate / hash / install) failed previously.
|
||||
///
|
||||
/// Both leave the sidecar with `expected_size == file_size`. Resuming
|
||||
/// from that offset would send `Range: bytes=N-` for an N-byte file and
|
||||
/// get HTTP 416. Verify we skip the network request entirely and let the
|
||||
/// install path validate the existing bytes.
|
||||
#[serial]
|
||||
#[test]
|
||||
fn full_size_partial_skips_download_and_installs() -> Result<()> {
|
||||
let tmp_dir = TempDir::new().unwrap();
|
||||
init_for_testing(&tmp_dir, None);
|
||||
|
||||
let base = "hello world";
|
||||
let apk_path = tmp_dir.path().join("base.apk");
|
||||
write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
|
||||
|
||||
// Simulate the stuck state: full-size partial file plus matching sidecar.
|
||||
let download_dir = tmp_dir.path().join("downloads");
|
||||
fs::create_dir_all(&download_dir)?;
|
||||
let download_path = download_dir.join("1");
|
||||
fs::write(&download_path, PATCH_BYTES)?;
|
||||
crate::download_state::write_download_state(
|
||||
&download_path,
|
||||
&crate::download_state::DownloadState {
|
||||
url: "http://example.com/patch/1".to_string(),
|
||||
patch_number: 1,
|
||||
expected_size: Some(PATCH_BYTES.len() as u64),
|
||||
expected_hash: PATCH_HASH.to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
// The download hook must NOT be called — bytes are already on disk.
|
||||
testing_set_network_hooks(
|
||||
|_url, _request| {
|
||||
Ok(PatchCheckResponse {
|
||||
patch_available: true,
|
||||
patch: Some(crate::Patch {
|
||||
number: 1,
|
||||
hash: PATCH_HASH.to_string(),
|
||||
download_url: "http://example.com/patch/1".to_string(),
|
||||
hash_signature: None,
|
||||
}),
|
||||
rolled_back_patch_numbers: None,
|
||||
})
|
||||
},
|
||||
crate::network::UNEXPECTED_DOWNLOAD,
|
||||
|_url, _event| Ok(()),
|
||||
);
|
||||
|
||||
let result = crate::update(None)?;
|
||||
assert_eq!(result, crate::UpdateStatus::UpdateInstalled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user