feat: add resumable downloads with streaming to disk (#313)

* feat: add resumable downloads with streaming to disk

Previously, failed patch downloads were lost entirely — the updater
would re-download from scratch on every retry, creating a doom loop for
users on poor networks. Downloads were also fully buffered in memory.

This changes the download abstraction from returning bytes in memory
(`DownloadFileFn`) to streaming directly to disk with resume support
(`DownloadToPathFn`). The new signature accepts a `resume_from` byte
offset and the default implementation uses HTTP Range headers.

Key changes:
- DownloadToPathFn streams to file, sends Range header when resuming
- DownloadResult returns total_bytes and content_length from server
- DownloadState sidecar JSON tracks URL/patch/size for resume decisions
- compute_resume_offset detects valid partial downloads to resume
- Post-download size validation catches truncated downloads
- cleanup_download_artifacts removes compressed files + sidecars after
  successful install (fixes pre-existing leak of download artifacts)

The fn-pointer signature maps directly to a future C callback for
platform-native download backends (iOS NSURLSession, Android
DownloadManager).

* fix: address self-review issues in download implementation

- Parse Content-Range header for 206 responses to get total file size
  (reqwest's content_length() only returns the partial body size)
- Only append to existing file when server actually returns 206, not
  just when resume_from > 0 (handles servers that ignore Range header)
- Remove incorrect resume_from offset addition to expected_size
- Clean up download artifacts on size mismatch before bailing
- Restore parent directory creation in download_to_path wrapper

* refactor: remove expected_hash from DownloadState

The hash stored in the sidecar was the *inflated* file hash from the
server response — it couldn't validate the compressed partial download
and was never used for resume decisions. The URL match already
determines whether to resume, and the real hash check happens after
inflate using the fresh server response. Simplifies the sidecar to
just url, patch_number, and expected_size.

* test: add coverage for resumable downloads + review fixes

- Add 12 new tests covering:
  - compute_resume_offset: no sidecar, matching sidecar, mismatched URL,
    empty file
  - cleanup_download_artifacts: removes file+sidecar, noop when missing
  - Integration: successful update cleans up artifacts
  - Integration: partial download resumes via 206 with mockito
  - Integration: URL change triggers fresh download
  - parse_content_range_total: valid, missing header, unknown size (*)

- Extract parse_content_range_total as testable helper (was inline chain)
- Add expected_hash back to DownloadState — catches the case where a
  patch is deleted and re-added with same number but different content
- Add "rsplit" to cspell config

* feat: add orphan cleanup for download directory + WriteFile comment

Scan the download directory before each download and remove any files
that don't belong to the current patch number. We own this directory
entirely, so anything from a prior patch, a crashed inflate (.full),
or an unrecognized file is safe to delete. This prevents gradual
accumulation of orphaned partial downloads over time.

Also adds a TODO comment explaining the reuse of WriteFile context for
seek operations (FileOperation lacks a SeekFile variant).

* test: add coverage for hash mismatch, patch number mismatch, corrupt sidecar

- compute_resume_offset_mismatched_hash: same URL but hash changed
  (patch deleted and re-added), verifies fresh download
- compute_resume_offset_mismatched_patch_number: sidecar for different
  patch number, verifies fresh download
- compute_resume_offset_corrupt_sidecar: garbage JSON in sidecar,
  verifies graceful fallback to fresh download

* test: cover download size mismatch and unknown content-length paths

- update_fails_on_download_size_mismatch: mock returns content_length
  that doesn't match total_bytes, verifies error + artifact cleanup
- update_succeeds_when_content_length_unknown: verifies the size
  validation is skipped when content_length is None

* fix: replace fake hash strings to pass cspell

* chore: remove unnecessary TODO comment about FileOperation::SeekFile

* refactor: panic in test download mocks that should never be called

Tests where the download is never reached (patch check fails or no
patch available) now panic instead of returning dummy data, making it
explicit that the mock shouldn't be invoked.

* refactor: add UNEXPECTED_DOWNLOAD/UNEXPECTED_REPORT test constants

Shared panicking constants for test mocks that should never be called.
Tests use these by name instead of writing inline panic closures,
making intent clearer and avoiding uncoverable dead code in closures.

* test: add coverage for handle_download_result

Tests for the download-specific HTTP response handler:
- 200 OK: accepted
- 206 Partial Content: accepted (for resumed downloads)
- 500: rejected with error message

* fix: update missed download fn signatures in tests

Two test sites in updater.rs were not updated to the new
DownloadToPathFn 3-argument signature:
- set_noop_network_hooks in multi_engine_tests used old 1-arg closure
- update_starts_fresh_when_url_changes had unused patch_bytes variable

* test: add coverage for handle_download_result error branches

Mirror the existing handle_network_result_no_internet and
handle_network_result_unknown_error tests for the download variant.
These exercise the connection error and builder error paths in
handle_download_result that were previously uncovered.

* fix: adapt resumable downloads to ureq (post-rebase cleanup)

- Replace reqwest with ureq for download_to_path_default
- Remove handle_download_result (ureq's handle_network_result handles 206)
- Fix TempDir::new("prefix") → TempDir::new() for tempfile crate
- Remove unused Read/Write imports
This commit is contained in:
Eric Seidel
2026-04-01 08:51:16 -07:00
committed by GitHub
parent 2057fd4f46
commit 463ace0c56
6 changed files with 1031 additions and 75 deletions
+1
View File
@@ -58,6 +58,7 @@ words:
- pubspec
- repr
- reqwest
- rsplit
- rollouts
- rustflags
- rustls
+30 -22
View File
@@ -390,11 +390,14 @@ pub extern "C" fn shorebird_report_launch_success() {
mod test {
use super::*;
use crate::{
network::{testing_set_network_hooks, PatchCheckResponse},
network::{
testing_set_network_hooks, DownloadResult, PatchCheckResponse, UNEXPECTED_DOWNLOAD,
},
test_utils::write_fake_apk,
};
use anyhow::Ok;
use serial_test::serial;
use std::path::Path;
use tempfile::TempDir;
use updater::testing_reset_config;
@@ -602,13 +605,18 @@ mod test {
rolled_back_patch_numbers: None,
})
},
|_url| {
|_url, dest: &Path, _resume_from: u64| {
// Generated by `string_patch "hello world" "hello tests"`
let patch_bytes: Vec<u8> = vec![
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
];
Ok(patch_bytes)
let total_bytes = patch_bytes.len() as u64;
std::fs::write(dest, &patch_bytes)?;
Ok(DownloadResult {
total_bytes,
content_length: Some(total_bytes),
})
},
|_url, _event| Ok(()),
);
@@ -665,13 +673,18 @@ mod test {
rolled_back_patch_numbers: None,
})
},
|_url| {
|_url, dest: &Path, _resume_from: u64| {
// Generated by `string_patch "hello world" "hello tests"`
let patch_bytes: Vec<u8> = vec![
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
];
Ok(patch_bytes)
let total_bytes = patch_bytes.len() as u64;
std::fs::write(dest, &patch_bytes)?;
Ok(DownloadResult {
total_bytes,
content_length: Some(total_bytes),
})
},
|_url, _event| Ok(()),
);
@@ -728,7 +741,7 @@ mod test {
rolled_back_patch_numbers: None,
})
},
|_url| Err(anyhow::anyhow!("Error")),
UNEXPECTED_DOWNLOAD,
|_url, _event| Ok(()),
);
@@ -759,17 +772,10 @@ mod test {
free_c_string(c_yaml);
free_parameters(c_params);
// set up the network hooks to return a patch.
// set up the network hooks — patch check fails, so download should never be called.
testing_set_network_hooks(
|_url, _request| Err(anyhow::anyhow!("Error")),
|_url| {
// Generated by `string_patch "hello world" "hello tests"`
let patch_bytes: Vec<u8> = vec![
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
];
Ok(patch_bytes)
},
UNEXPECTED_DOWNLOAD,
|_url, _event| Ok(()),
);
@@ -820,7 +826,7 @@ mod test {
rolled_back_patch_numbers: None,
})
},
|_url| Err(anyhow::anyhow!("Error")),
|_url, _dest: &Path, _resume_from: u64| Err(anyhow::anyhow!("Error")),
|_url, _event| Ok(()),
);
@@ -871,13 +877,18 @@ mod test {
rolled_back_patch_numbers: None,
})
},
|_url| {
|_url, dest: &Path, _resume_from: u64| {
// Generated by `string_patch "hello world" "hello tests"`
let patch_bytes: Vec<u8> = vec![
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
];
Ok(patch_bytes)
let total_bytes = patch_bytes.len() as u64;
std::fs::write(dest, &patch_bytes)?;
Ok(DownloadResult {
total_bytes,
content_length: Some(total_bytes),
})
},
|_url, _event| Ok(()),
);
@@ -981,10 +992,7 @@ mod test {
rolled_back_patch_numbers: None,
})
},
|_url| {
// Never called.
Ok(Vec::new())
},
UNEXPECTED_DOWNLOAD,
|_url, _event| Ok(()),
);
{
+126
View File
@@ -0,0 +1,126 @@
/// Tracks metadata about an in-progress patch download so that it can be
/// resumed after a failure or app restart.
///
/// Stored as a sidecar JSON file alongside the partial download:
/// {download_dir}/{patch_number}.download.json
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::file_errors::{FileOperation, IoResultExt};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DownloadState {
/// The URL this download was started from. Used to decide whether a
/// partial file on disk matches the current server response — if the URL
/// changed, we discard and start fresh.
pub url: String,
/// The patch number being downloaded.
pub patch_number: usize,
/// Expected total file size from Content-Length/Content-Range (if known
/// from a prior download attempt). Used for post-download validation.
pub expected_size: Option<u64>,
/// Hash of the inflated patch from the server response. Checked on resume
/// to catch the case where a patch is deleted and re-added with the same
/// number but different content (URL may stay the same but hash changes).
pub expected_hash: String,
}
/// Returns the sidecar path for a given download path.
/// e.g. "{download_dir}/1" -> "{download_dir}/1.download.json"
pub fn sidecar_path(download_path: &Path) -> PathBuf {
let mut p = download_path.as_os_str().to_owned();
p.push(".download.json");
PathBuf::from(p)
}
/// Write a DownloadState to its sidecar file.
pub fn write_download_state(download_path: &Path, state: &DownloadState) -> anyhow::Result<()> {
let path = sidecar_path(download_path);
let json = serde_json::to_string(state)?;
std::fs::write(&path, json).with_file_context(FileOperation::WriteFile, &path)?;
Ok(())
}
/// Read a DownloadState from its sidecar file, if it exists.
pub fn read_download_state(download_path: &Path) -> anyhow::Result<Option<DownloadState>> {
let path = sidecar_path(download_path);
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path).with_file_context(FileOperation::ReadFile, &path)?;
let state: DownloadState = serde_json::from_str(&json)?;
Ok(Some(state))
}
/// Delete the sidecar file for a download, if it exists.
pub fn delete_download_state(download_path: &Path) -> anyhow::Result<()> {
let path = sidecar_path(download_path);
if path.exists() {
std::fs::remove_file(&path).with_file_context(FileOperation::DeleteFile, &path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn round_trip_download_state() {
let tmp = TempDir::new().unwrap();
let download_path = tmp.path().join("1");
let state = DownloadState {
url: "https://example.com/patch/1".to_string(),
patch_number: 1,
expected_size: Some(12345),
expected_hash: "abc123".to_string(),
};
write_download_state(&download_path, &state).unwrap();
let loaded = read_download_state(&download_path).unwrap();
assert_eq!(loaded, Some(state));
}
#[test]
fn read_missing_returns_none() {
let tmp = TempDir::new().unwrap();
let download_path = tmp.path().join("99");
let loaded = read_download_state(&download_path).unwrap();
assert_eq!(loaded, None);
}
#[test]
fn delete_removes_sidecar() {
let tmp = TempDir::new().unwrap();
let download_path = tmp.path().join("1");
let state = DownloadState {
url: "https://example.com/patch/1".to_string(),
patch_number: 1,
expected_size: None,
expected_hash: "abc".to_string(),
};
write_download_state(&download_path, &state).unwrap();
assert!(sidecar_path(&download_path).exists());
delete_download_state(&download_path).unwrap();
assert!(!sidecar_path(&download_path).exists());
}
#[test]
fn delete_missing_is_ok() {
let tmp = TempDir::new().unwrap();
let download_path = tmp.path().join("99");
// Should not error.
delete_download_state(&download_path).unwrap();
}
#[test]
fn sidecar_path_is_correct() {
let p = sidecar_path(Path::new("/cache/downloads/1"));
assert_eq!(p, PathBuf::from("/cache/downloads/1.download.json"));
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod c_api;
// Declare other .rs file/module exists, but make them private.
mod cache;
mod config;
mod download_state;
mod events;
mod file_errors;
mod logging;
+140 -36
View File
@@ -3,8 +3,8 @@
use anyhow::bail;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{Read, Write};
use std::fs::{File, OpenOptions};
use std::io::{Seek, SeekFrom};
use std::path::Path;
use std::string::ToString;
@@ -21,16 +21,26 @@ fn patches_events_url(base_url: &str) -> String {
}
pub type PatchCheckRequestFn = fn(&str, PatchCheckRequest) -> anyhow::Result<PatchCheckResponse>;
pub type DownloadFileFn = fn(&str) -> anyhow::Result<Vec<u8>>;
pub type DownloadToPathFn =
fn(url: &str, dest: &Path, resume_from: u64) -> anyhow::Result<DownloadResult>;
pub type ReportEventFn = fn(&str, CreatePatchEventRequest) -> anyhow::Result<()>;
/// Result of a download operation.
#[derive(Debug, Clone)]
pub struct DownloadResult {
/// Total bytes written to the file (including any previously downloaded bytes on resume).
pub total_bytes: u64,
/// The Content-Length from the server response, if present.
pub content_length: Option<u64>,
}
/// A container for network callbacks which can be mocked out for testing.
#[derive(Clone)]
pub struct NetworkHooks {
/// The function to call to send a patch check request.
pub patch_check_request_fn: PatchCheckRequestFn,
/// The function to call to download a file.
pub download_file_fn: DownloadFileFn,
pub download_to_path_fn: DownloadToPathFn,
/// The function to call to report patch install success.
pub report_event_fn: ReportEventFn,
}
@@ -40,7 +50,7 @@ impl core::fmt::Debug for NetworkHooks {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NetworkHooks")
.field("patch_check_request_fn", &"<fn>")
.field("download_file_fn", &"<fn>")
.field("download_to_path_fn", &"<fn>")
.field("report_event_fn", &"<fn>")
.finish()
}
@@ -50,7 +60,7 @@ impl Default for NetworkHooks {
fn default() -> Self {
Self {
patch_check_request_fn: patch_check_request_default,
download_file_fn: download_file_default,
download_to_path_fn: download_to_path_default,
report_event_fn: report_event_default,
}
}
@@ -68,13 +78,59 @@ pub fn patch_check_request_default(
Ok(parsed)
}
pub fn download_file_default(url: &str) -> anyhow::Result<Vec<u8>> {
let result = ureq::get(url).call();
/// Default download implementation that streams to a file with Range header
/// support for resuming partial downloads.
pub fn download_to_path_default(
url: &str,
dest: &Path,
resume_from: u64,
) -> anyhow::Result<DownloadResult> {
let mut request = ureq::get(url);
if resume_from > 0 {
request = request.header("Range", &format!("bytes={resume_from}-"));
}
let result = request.call();
let response = handle_network_result(result)?;
let mut bytes = Vec::new();
response.into_body().as_reader().read_to_end(&mut bytes)?;
// Patch files are small (e.g. 50kb) so this should be ok to copy into memory.
Ok(bytes)
let status = response.status();
// Determine total file size from headers.
let content_length = if status == 206 {
parse_content_range_total(response.headers())
} else {
// 200: Content-Length is the full file size.
response
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
};
// Only resume (append) when the server actually returned 206.
// If the server ignored our Range header and returned 200, start fresh.
let mut file = if status == 206 && resume_from > 0 {
let mut f = OpenOptions::new()
.write(true)
.open(dest)
.with_file_context(FileOperation::WriteFile, dest)?;
f.seek(SeekFrom::Start(resume_from))
.with_file_context(FileOperation::WriteFile, dest)?;
f
} else {
// Fresh download (200 OK or server ignored Range): create/truncate.
File::create(dest).with_file_context(FileOperation::CreateFile, dest)?
};
std::io::copy(&mut response.into_body().as_reader(), &mut file)
.with_file_context(FileOperation::WriteFile, dest)?;
let total_bytes = std::fs::metadata(dest)
.with_file_context(FileOperation::ReadFile, dest)?
.len();
Ok(DownloadResult {
total_bytes,
content_length,
})
}
pub fn report_event_default(url: &str, request: CreatePatchEventRequest) -> anyhow::Result<()> {
@@ -105,18 +161,39 @@ fn handle_network_result(
}
}
/// Parses the total file size from a Content-Range header.
/// Expected format: `bytes start-end/total` (e.g. `bytes 100-199/1000`).
/// Returns `None` if the header is missing, malformed, or the total is `*`.
fn parse_content_range_total(headers: &ureq::http::HeaderMap) -> Option<u64> {
headers
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.rsplit('/').next())
.and_then(|v| v.parse::<u64>().ok())
}
#[cfg(test)]
/// Unit tests can call this to mock out the network calls.
/// Panicking placeholder for tests that should never reach the download step.
pub const UNEXPECTED_DOWNLOAD: DownloadToPathFn = |_, _, _| panic!("unexpected download call");
#[cfg(test)]
/// Panicking placeholder for tests that should never reach the report step.
pub const UNEXPECTED_REPORT: ReportEventFn = |_, _| panic!("unexpected report event call");
#[cfg(test)]
/// Unit tests can call this to mock out the network calls. Use
/// `UNEXPECTED_DOWNLOAD` or `UNEXPECTED_REPORT` for hooks that should
/// not be called in a given test.
pub fn testing_set_network_hooks(
patch_check_request_fn: PatchCheckRequestFn,
download_file_fn: DownloadFileFn,
download_to_path_fn: DownloadToPathFn,
report_event_fn: ReportEventFn,
) {
crate::config::with_config_mut(|maybe_config| match maybe_config {
Some(config) => {
config.network_hooks = NetworkHooks {
patch_check_request_fn,
download_file_fn,
download_to_path_fn,
report_event_fn,
};
}
@@ -217,34 +294,26 @@ pub fn send_patch_event(event: PatchEvent, config: &UpdateConfig) -> anyhow::Res
report_event_fn(url, request)
}
/// Downloads the file at `url` to `path`.
/// Downloads the file at `url` to `path`, optionally resuming from byte offset
/// `resume_from`. Ensures the parent directory exists before downloading.
pub fn download_to_path(
network_hooks: &NetworkHooks,
url: &str,
path: &Path,
) -> anyhow::Result<()> {
resume_from: u64,
) -> anyhow::Result<DownloadResult> {
shorebird_info!("Downloading patch from: {}", url);
// Download the file at the given url to the given path.
let download_file_hook = network_hooks.download_file_fn;
let bytes = download_file_hook(url)?;
// Ensure the download directory exists.
if let Some(parent) = path.parent() {
shorebird_debug!("Creating download directory: {:?}", parent);
std::fs::create_dir_all(parent)
.with_file_context(FileOperation::CreateDir, parent)?;
std::fs::create_dir_all(parent).with_file_context(FileOperation::CreateDir, parent)?;
}
let download_hook = network_hooks.download_to_path_fn;
let result = download_hook(url, path, resume_from)?;
shorebird_info!(
"Writing {} bytes to: {:?}",
bytes.len(),
path
"Downloaded patch to: {:?} ({} bytes)",
path,
result.total_bytes
);
let mut file = File::create(path)
.with_file_context(FileOperation::CreateFile, path)?;
file.write_all(&bytes)
.with_file_context(FileOperation::WriteFile, path)?;
shorebird_info!("Wrote {} bytes to: {:?}", bytes.len(), path);
Ok(())
Ok(result)
}
#[cfg(test)]
@@ -337,7 +406,7 @@ mod tests {
},
);
assert!(result.is_err());
let result = (network_hooks.download_file_fn)("");
let result = (network_hooks.download_to_path_fn)("", std::path::Path::new("/tmp/test"), 0);
assert!(result.is_err());
}
@@ -346,10 +415,45 @@ mod tests {
let network_hooks = super::NetworkHooks::default();
let debug = format!("{:?}", network_hooks);
assert!(debug.contains("patch_check_request_fn"));
assert!(debug.contains("download_file_fn"));
assert!(debug.contains("download_to_path_fn"));
assert!(debug.contains("report_event_fn"));
}
#[test]
fn parse_content_range_total_valid() {
let mut headers = ureq::http::HeaderMap::new();
headers.insert("content-range", "bytes 100-199/1000".parse().unwrap());
assert_eq!(super::parse_content_range_total(&headers), Some(1000));
}
#[test]
fn parse_content_range_total_missing() {
let headers = ureq::http::HeaderMap::new();
assert_eq!(super::parse_content_range_total(&headers), None);
}
#[test]
fn parse_content_range_total_unknown_size() {
let mut headers = ureq::http::HeaderMap::new();
headers.insert("content-range", "bytes 100-199/*".parse().unwrap());
assert_eq!(super::parse_content_range_total(&headers), None);
}
#[test]
fn download_to_path_no_internet() {
let dest = std::path::Path::new("/tmp/updater_test_no_internet");
let result = super::download_to_path_default(
"http://asdfasdfasdfasdfasdf.asdfasdf/patch/1",
dest,
0,
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Patch check request failed due to network error. Please check your internet connection."
);
}
#[test]
fn handle_network_result_ok() {
let body = ureq::Body::builder()
+733 -17
View File
@@ -5,12 +5,13 @@ use std::fs::{self};
use std::io::{Cursor, Read, Seek};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::file_errors::{FileOperation, IoResultExt};
use anyhow::{bail, Context, Result};
use dyn_clone::DynClone;
use crate::cache::{PatchInfo, UpdaterState};
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};
@@ -21,7 +22,7 @@ use crate::yaml::YamlConfig;
// Expose testing_reset_config for integration tests.
pub use crate::config::testing_reset_config;
#[cfg(test)]
pub use crate::network::{DownloadFileFn, Patch, PatchCheckRequestFn};
pub use crate::network::{DownloadToPathFn, Patch, PatchCheckRequestFn};
#[derive(Debug, PartialEq)]
pub enum UpdateStatus {
@@ -298,11 +299,9 @@ fn check_hash(path: &Path, expected_string: &str) -> anyhow::Result<()> {
// Based on guidance from:
// <https://github.com/RustCrypto/hashes#hashing-readable-objects>
let mut file = fs::File::open(path)
.with_file_context(FileOperation::ReadFile, path)?;
let mut file = fs::File::open(path).with_file_context(FileOperation::ReadFile, path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher)
.with_file_context(FileOperation::ReadFile, path)?;
std::io::copy(&mut file, &mut hasher).with_file_context(FileOperation::ReadFile, path)?;
// Check that the length from copy is the same as the file size?
let hash = hasher.finalize();
let hash_matches = hash.as_slice() == expected;
@@ -425,8 +424,63 @@ 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(
&download_path,
&patch.download_url,
patch.number,
&patch.hash,
);
// Ensure the download directory exists.
std::fs::create_dir_all(&download_dir)
.with_file_context(FileOperation::CreateDir, &download_dir)?;
// Clean up any orphaned files in the download directory. We own this
// directory entirely, so anything that isn't for the current patch is
// stale (e.g. from a prior patch number, a crashed inflate, or a
// 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(),
};
download_state::write_download_state(&download_path, &dl_state)?;
// Consider supporting allowing the system to download for us (e.g. iOS).
download_to_path(&config.network_hooks, &patch.download_url, &download_path)?;
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)?;
// Validate download size if Content-Length was provided.
if let Some(expected) = dl_state.expected_size {
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,
dl_result.total_bytes
);
}
}
let output_path = download_dir.join(format!("{}.full", patch.number));
let patch_base_rs = patch_base(&config)?;
@@ -456,6 +510,9 @@ 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();
std::thread::spawn(move || {
let event = PatchEvent::new(
@@ -510,6 +567,89 @@ 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(
download_path: &Path,
url: &str,
patch_number: usize,
expected_hash: &str,
) -> u64 {
// Check for a sidecar file describing a prior download attempt.
let prior_state = match download_state::read_download_state(download_path) {
Ok(Some(state)) => state,
_ => return 0,
};
// 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.
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;
}
// 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()
}
_ => 0,
}
}
/// Removes everything in `download_dir` except files belonging to
/// `current_patch_number`. We own this directory entirely, so anything
/// unrecognized or from a different patch number is safe to delete.
fn clean_download_dir(download_dir: &Path, current_patch_number: usize) {
let entries = match fs::read_dir(download_dir) {
Ok(entries) => entries,
Err(_) => return, // Directory may not exist yet.
};
let current_prefix = current_patch_number.to_string();
for entry in entries.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
// Keep files that belong to the current patch:
// "{number}", "{number}.full", "{number}.download.json"
if name == current_prefix
|| name == format!("{current_prefix}.full")
|| name == format!("{current_prefix}.download.json")
{
continue;
}
// Everything else is an orphan — delete it.
let path = entry.path();
if path.is_file() {
if let Err(e) = fs::remove_file(&path) {
shorebird_error!("Failed to clean up orphaned file {:?}: {:?}", path, e);
} else {
shorebird_info!("Cleaned up orphaned download file: {:?}", path);
}
}
}
}
/// Removes the compressed download file and its sidecar after a successful
/// install.
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);
}
if download_path.exists() {
if let Err(e) = std::fs::remove_file(download_path) {
shorebird_error!("Failed to delete download file: {:?}", e);
}
}
}
/// Synchronously checks for an update and downloads and installs it if available.
pub fn update(channel: Option<&str>) -> anyhow::Result<UpdateStatus> {
with_updater_thread_lock(|lock_state| update_internal(lock_state, channel))
@@ -571,11 +711,10 @@ where
// PipeReader/Writer errors instead of file open errors.
shorebird_info!("Inflating patch from {:?}", patch_path);
let compressed_patch_r = BufReader::new(
fs::File::open(patch_path)
.with_file_context(FileOperation::ReadFile, patch_path)?,
fs::File::open(patch_path).with_file_context(FileOperation::ReadFile, patch_path)?,
);
let output_file_w = fs::File::create(output_path)
.with_file_context(FileOperation::CreateFile, output_path)?;
let output_file_w =
fs::File::create(output_path).with_file_context(FileOperation::CreateFile, output_path)?;
// Set up a pipe to connect the writing from the decompression thread
// to the reading of the decompressed patch data on this thread.
@@ -590,8 +729,8 @@ where
});
// Do the patch, using the uncompressed patch data from the pipe.
let mut fresh_r = bipatch::Reader::new(patch_r, base_r)
.context("Failed to initialize patch reader")?;
let mut fresh_r =
bipatch::Reader::new(patch_r, base_r).context("Failed to initialize patch reader")?;
// Write out the resulting patched file to the new location.
let mut output_w = BufWriter::new(output_file_w);
@@ -788,6 +927,7 @@ pub fn start_update_thread() {
#[cfg(test)]
mod tests {
use serial_test::serial;
use std::path::Path;
use std::{fs, thread, time::Duration};
use tempfile::TempDir;
@@ -1578,6 +1718,577 @@ 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();
// 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.
crate::download_state::write_download_state(
&download_path,
&crate::download_state::DownloadState {
url: "http://example.com/patch".to_string(),
patch_number: 1,
expected_size: Some(1000),
expected_hash: "abc123".to_string(),
},
)
.unwrap();
// Should resume from 500 bytes.
assert_eq!(
super::compute_resume_offset(&download_path, "http://example.com/patch", 1, "abc123"),
500
);
}
#[test]
fn compute_resume_offset_mismatched_url() {
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 {
url: "http://example.com/old-patch".to_string(),
patch_number: 1,
expected_size: None,
expected_hash: "abc123".to_string(),
},
)
.unwrap();
// Different URL → fresh download.
assert_eq!(
super::compute_resume_offset(
&download_path,
"http://example.com/new-patch",
1,
"abc123"
),
0
);
}
#[test]
fn compute_resume_offset_mismatched_hash() {
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 {
url: "http://example.com/patch".to_string(),
patch_number: 1,
expected_size: None,
expected_hash: "hash_old".to_string(),
},
)
.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
);
}
#[test]
fn compute_resume_offset_mismatched_patch_number() {
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 {
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
);
}
#[test]
fn compute_resume_offset_corrupt_sidecar() {
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
);
}
#[test]
fn compute_resume_offset_empty_file() {
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();
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
);
}
#[test]
fn cleanup_download_artifacts_removes_file_and_sidecar() {
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, b"partial data").unwrap();
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();
let sidecar = crate::download_state::sidecar_path(&download_path);
assert!(download_path.exists());
assert!(sidecar.exists());
super::cleanup_download_artifacts(&download_path);
assert!(!download_path.exists());
assert!(!sidecar.exists());
}
#[test]
fn cleanup_download_artifacts_noop_when_missing() {
let tmp = TempDir::new().unwrap();
let download_path = tmp.path().join("downloads/1");
// Should not panic when files don't exist.
super::cleanup_download_artifacts(&download_path);
}
#[test]
fn clean_download_dir_removes_orphans_keeps_current() {
let tmp = TempDir::new().unwrap();
let download_dir = tmp.path().join("downloads");
fs::create_dir_all(&download_dir).unwrap();
// Files for current patch (number 3) — should be kept.
fs::write(download_dir.join("3"), b"compressed").unwrap();
fs::write(download_dir.join("3.full"), b"inflated").unwrap();
fs::write(download_dir.join("3.download.json"), b"{}").unwrap();
// Files for old patches — should be deleted.
fs::write(download_dir.join("1"), b"old compressed").unwrap();
fs::write(download_dir.join("1.full"), b"old inflated").unwrap();
fs::write(download_dir.join("1.download.json"), b"{}").unwrap();
fs::write(download_dir.join("2"), b"old compressed").unwrap();
// Unrecognized file — should be deleted.
fs::write(download_dir.join("garbage.tmp"), b"junk").unwrap();
super::clean_download_dir(&download_dir, 3);
// Current patch files preserved.
assert!(download_dir.join("3").exists());
assert!(download_dir.join("3.full").exists());
assert!(download_dir.join("3.download.json").exists());
// Old and unrecognized files removed.
assert!(!download_dir.join("1").exists());
assert!(!download_dir.join("1.full").exists());
assert!(!download_dir.join("1.download.json").exists());
assert!(!download_dir.join("2").exists());
assert!(!download_dir.join("garbage.tmp").exists());
}
#[test]
fn clean_download_dir_noop_when_dir_missing() {
let tmp = TempDir::new().unwrap();
let download_dir = tmp.path().join("nonexistent");
// Should not panic.
super::clean_download_dir(&download_dir, 1);
}
#[serial]
#[test]
fn successful_update_cleans_up_download_artifacts() -> anyhow::Result<()> {
let mut server = mockito::Server::new();
let download_url = format!("{}/patch/1", server.url());
let check_response = PatchCheckResponse {
patch_available: true,
patch: Some(Patch {
number: 1,
download_url: download_url.to_string(),
hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45"
.to_string(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
};
let check_response_body = serde_json::to_string(&check_response).unwrap();
let _ = server
.mock("POST", "/api/v1/patches/check")
.with_status(200)
.with_body(check_response_body)
.create();
let _ = server
.mock("GET", "/patch/1")
.with_status(200)
.with_body(
// Generated by `string_patch "hello world" "hello tests"`
[
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
],
)
.create();
let _ = server
.mock("POST", "/api/v1/patches/events")
.with_status(201)
.create();
let tmp_dir = TempDir::new().unwrap();
init_for_testing(&tmp_dir, Some(&server.url()));
let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
let result = super::update(None)?;
assert_eq!(result, crate::UpdateStatus::UpdateInstalled);
// After successful install, compressed download and sidecar should be cleaned up.
let download_path = tmp_dir.path().join("downloads/1");
let sidecar_path = crate::download_state::sidecar_path(&download_path);
assert!(
!download_path.exists(),
"compressed download should be deleted"
);
assert!(!sidecar_path.exists(), "sidecar should be deleted");
Ok(())
}
#[serial]
#[test]
fn update_fails_on_download_size_mismatch() -> anyhow::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");
crate::test_utils::write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
testing_set_network_hooks(
|_url, _request| {
Ok(PatchCheckResponse {
patch_available: true,
patch: Some(Patch {
number: 1,
hash: "abc123".to_string(),
download_url: "http://example.com/patch/1".to_string(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
})
},
|_url, dest: &Path, _resume_from: u64| {
// Write 10 bytes but claim the server said 9999.
let data = vec![0u8; 10];
std::fs::write(dest, &data)?;
Ok(crate::network::DownloadResult {
total_bytes: 10,
content_length: Some(9999),
})
},
|_url, _event| Ok(()),
);
let result = super::update(None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Download size mismatch"),
"Expected size mismatch error, got: {err}"
);
// Verify artifacts were cleaned up after the mismatch.
let download_path = tmp_dir.path().join("downloads/1");
let sidecar_path = crate::download_state::sidecar_path(&download_path);
assert!(!download_path.exists(), "download should be cleaned up");
assert!(!sidecar_path.exists(), "sidecar should be cleaned up");
Ok(())
}
#[serial]
#[test]
fn update_succeeds_when_content_length_unknown() -> anyhow::Result<()> {
// When the server doesn't provide Content-Length (content_length: None),
// the size check should be skipped and the update should proceed.
let mut server = mockito::Server::new();
let download_url = format!("{}/patch/1", server.url());
let check_response = PatchCheckResponse {
patch_available: true,
patch: Some(Patch {
number: 1,
download_url: download_url.to_string(),
hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45"
.to_string(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
};
let check_response_body = serde_json::to_string(&check_response).unwrap();
let _ = server
.mock("POST", "/api/v1/patches/check")
.with_status(200)
.with_body(check_response_body)
.create();
// Serve without Content-Length by using chunked transfer.
let _ = server
.mock("GET", "/patch/1")
.with_status(200)
.with_body(
// Generated by `string_patch "hello world" "hello tests"`
[
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
],
)
.create();
let _ = server
.mock("POST", "/api/v1/patches/events")
.with_status(201)
.create();
let tmp_dir = TempDir::new().unwrap();
init_for_testing(&tmp_dir, Some(&server.url()));
let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
crate::test_utils::write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
let result = super::update(None)?;
assert_eq!(result, crate::UpdateStatus::UpdateInstalled);
Ok(())
}
#[serial]
#[test]
fn update_resumes_partial_download() -> anyhow::Result<()> {
// This test verifies that if a partial download + sidecar exist from a
// prior attempt, the updater sends a Range header to resume.
let mut server = mockito::Server::new();
let download_url = format!("{}/patch/1", server.url());
// Generated by `string_patch "hello world" "hello tests"`
let patch_bytes: Vec<u8> = vec![
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0, 0, 0, 0,
5, 116, 101, 115, 116, 115, 0,
];
let split_at = 10;
let first_part = &patch_bytes[..split_at];
let second_part = &patch_bytes[split_at..];
let check_response = PatchCheckResponse {
patch_available: true,
patch: Some(Patch {
number: 1,
download_url: download_url.to_string(),
hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45"
.to_string(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
};
let check_response_body = serde_json::to_string(&check_response).unwrap();
let _ = server
.mock("POST", "/api/v1/patches/check")
.with_status(200)
.with_body(&check_response_body)
.create();
// Serve only the remaining bytes with 206 and Content-Range.
let _ = server
.mock("GET", "/patch/1")
.match_header("Range", format!("bytes={split_at}-").as_str())
.with_status(206)
.with_header(
"Content-Range",
&format!(
"bytes {}-{}/{}",
split_at,
patch_bytes.len() - 1,
patch_bytes.len()
),
)
.with_body(second_part)
.create();
let _ = server
.mock("POST", "/api/v1/patches/events")
.with_status(201)
.create();
let tmp_dir = TempDir::new().unwrap();
init_for_testing(&tmp_dir, Some(&server.url()));
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 a prior partial download: write the first 10 bytes + sidecar.
let download_dir = tmp_dir.path().join("downloads");
fs::create_dir_all(&download_dir).unwrap();
let download_path = download_dir.join("1");
fs::write(&download_path, first_part).unwrap();
crate::download_state::write_download_state(
&download_path,
&crate::download_state::DownloadState {
url: download_url.to_string(),
patch_number: 1,
expected_size: None,
expected_hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45"
.to_string(),
},
)
.unwrap();
// Run update — should resume from byte 10.
let result = super::update(None)?;
assert_eq!(result, crate::UpdateStatus::UpdateInstalled);
// Verify the patched file was written correctly.
crate::updater::with_mut_state(|state| {
assert_eq!(state.next_boot_patch().unwrap().number, 1);
Ok(())
})?;
Ok(())
}
#[serial]
#[test]
fn update_starts_fresh_when_url_changes() -> anyhow::Result<()> {
let mut server = mockito::Server::new();
let download_url = format!("{}/patch/1", server.url());
let check_response = PatchCheckResponse {
patch_available: true,
patch: Some(Patch {
number: 1,
download_url: download_url.to_string(),
hash: "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45"
.to_string(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
};
let check_response_body = serde_json::to_string(&check_response).unwrap();
let _ = server
.mock("POST", "/api/v1/patches/check")
.with_status(200)
.with_body(check_response_body)
.create();
// Full download (200), no Range header expected since URL changed.
let _ = server
.mock("GET", "/patch/1")
.with_status(200)
.with_body(
// Generated by `string_patch "hello world" "hello tests"`
[
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
],
)
.create();
let _ = server
.mock("POST", "/api/v1/patches/events")
.with_status(201)
.create();
let tmp_dir = TempDir::new().unwrap();
init_for_testing(&tmp_dir, Some(&server.url()));
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 prior partial download with a DIFFERENT URL.
let download_dir = tmp_dir.path().join("downloads");
fs::create_dir_all(&download_dir).unwrap();
let download_path = download_dir.join("1");
fs::write(&download_path, b"stale data from old url").unwrap();
crate::download_state::write_download_state(
&download_path,
&crate::download_state::DownloadState {
url: "http://old-cdn.example.com/patch/1".to_string(),
patch_number: 1,
expected_size: None,
expected_hash: "hash_old".to_string(),
},
)
.unwrap();
let result = super::update(None)?;
assert_eq!(result, crate::UpdateStatus::UpdateInstalled);
Ok(())
}
#[serial]
#[test]
fn no_config_lock_contention_when_waiting_for_patch_check() {
@@ -1604,13 +2315,18 @@ patch_verification: bogus_mode
// If we have not yet finished with the config lock, this test has failed.
unreachable!("If the test has not terminated before this, set_config is likely being blocked by a patch check request, which should not happen");
},
download_file_fn: |_url| Ok([].to_vec()),
download_to_path_fn: |_url, _dest: &Path, _resume_from: u64| {
Ok(crate::network::DownloadResult {
total_bytes: 0,
content_length: None,
})
},
report_event_fn: |_url, _event| Ok(()),
};
testing_set_network_hooks(
hooks.patch_check_request_fn,
hooks.download_file_fn,
hooks.download_to_path_fn,
hooks.report_event_fn,
);
@@ -2016,7 +2732,7 @@ mod multi_engine_tests {
use tempfile::TempDir;
use crate::{
network::{testing_set_network_hooks, PatchCheckResponse},
network::{testing_set_network_hooks, PatchCheckResponse, UNEXPECTED_DOWNLOAD},
report_launch_start, report_launch_success, test_utils::install_fake_patch,
updater::tests::init_for_testing, with_mut_state, with_state,
};
@@ -2034,7 +2750,7 @@ mod multi_engine_tests {
rolled_back_patch_numbers: None,
})
},
|_url| Ok(vec![]),
UNEXPECTED_DOWNLOAD,
|_url, _event| Ok(()),
);
}