Files
1jehuang--jcode/crates/jcode-app-core/src/update.rs
T
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

1716 lines
62 KiB
Rust

use crate::build;
use crate::storage;
use anyhow::{Context, Result};
use jcode_update_core::{
BACKGROUND_UPDATE_THRESHOLD, estimate_release_update_duration, estimate_source_update_duration,
format_duration_estimate, get_asset_name, summarize_git_pull_failure, update_estimate,
verify_asset_checksum_text, version_is_newer,
};
pub use jcode_update_core::{
DownloadProgress, GIT_PULL_DIVERGED_SUMMARY, GitHubAsset, GitHubRelease, PreparedUpdate,
UpdateCheckResult, UpdateEstimate, format_download_progress_bar, summary_is_divergence,
};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
const GITHUB_REPO: &str = "1jehuang/jcode";
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60); // minimum gap between checks
const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(5);
/// Time allowed for the initial TCP/TLS connect to the download host.
const DOWNLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// Total wall-clock budget for a single download *attempt*.
///
/// This is intentionally a per-attempt budget, not a budget for the whole
/// asset. The old code used a single 120s total timeout for the entire
/// transfer, so on a slow link a multi-megabyte asset could never finish: it
/// was killed mid-stream, the partial bytes were discarded, and every relaunch
/// restarted from zero. We now cap each attempt and resume via HTTP Range, so
/// a slow-but-progressing download completes across several attempts while a
/// genuinely hung connection still gets bounded.
const DOWNLOAD_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(120);
/// How many *consecutive* stalled attempts (attempts that made no forward
/// progress) to tolerate before giving up. Any attempt that downloads new
/// bytes resets this counter, so a slow-but-progressing download keeps
/// resuming via HTTP Range for as long as it needs; only a genuinely stuck
/// connection eventually fails.
const DOWNLOAD_MAX_ATTEMPTS: usize = 10;
const DOWNLOAD_PROGRESS_UPDATE_STEP: u64 = 1_048_576;
pub fn print_centered(msg: &str) {
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
for line in msg.lines() {
let visible_len = unicode_display_width(line);
if visible_len >= width {
println!("{}", line);
} else {
let pad = (width - visible_len) / 2;
println!("{:>pad$}{}", "", line, pad = pad);
}
}
}
fn unicode_display_width(s: &str) -> usize {
use unicode_width::UnicodeWidthChar;
let mut w = 0;
let mut in_escape = false;
for c in s.chars() {
if in_escape {
if c == 'm' {
in_escape = false;
}
continue;
}
if c == '\x1b' {
in_escape = true;
continue;
}
w += UnicodeWidthChar::width(c).unwrap_or(0);
}
w
}
pub fn is_release_build() -> bool {
jcode_build_meta::is_release_build()
}
fn current_update_semver() -> &'static str {
jcode_build_meta::UPDATE_SEMVER
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateMetadata {
pub last_check: SystemTime,
pub installed_version: Option<String>,
pub installed_from: Option<String>,
#[serde(default)]
pub last_release_update_secs: Option<f64>,
#[serde(default)]
pub last_source_update_secs: Option<f64>,
}
impl Default for UpdateMetadata {
fn default() -> Self {
Self {
last_check: SystemTime::UNIX_EPOCH,
installed_version: None,
installed_from: None,
last_release_update_secs: None,
last_source_update_secs: None,
}
}
}
impl UpdateMetadata {
pub fn load() -> Result<Self> {
let path = metadata_path()?;
if path.exists() {
let content = fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let path = metadata_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
fs::write(&path, content)?;
Ok(())
}
pub fn should_check(&self) -> bool {
match self.last_check.elapsed() {
Ok(elapsed) => elapsed > UPDATE_CHECK_INTERVAL,
Err(_) => true,
}
}
}
fn metadata_path() -> Result<PathBuf> {
Ok(storage::jcode_dir()?.join("update_metadata.json"))
}
fn source_build_root() -> Result<PathBuf> {
Ok(storage::jcode_dir()?.join("builds").join("source"))
}
fn source_build_repo_dir() -> Result<PathBuf> {
Ok(source_build_root()?.join("jcode"))
}
fn record_release_update_duration(duration: Duration) {
if let Ok(mut metadata) = UpdateMetadata::load() {
metadata.last_release_update_secs = Some(duration.as_secs_f64());
let _ = metadata.save();
}
}
fn record_source_update_duration(duration: Duration) {
if let Ok(mut metadata) = UpdateMetadata::load() {
metadata.last_source_update_secs = Some(duration.as_secs_f64());
let _ = metadata.save();
}
}
pub fn should_auto_update() -> bool {
if std::env::var("JCODE_NO_AUTO_UPDATE").is_ok() {
return false;
}
if !is_release_build() {
return false;
}
if let Ok(exe) = std::env::current_exe()
&& is_inside_git_repo(&exe)
{
return false;
}
true
}
pub fn run_git_pull_ff_only(repo_dir: &Path, quiet: bool) -> Result<()> {
let mut cmd = std::process::Command::new("git");
cmd.arg("pull").arg("--ff-only");
if quiet {
cmd.arg("-q");
}
let output = cmd
.current_dir(repo_dir)
.output()
.context("Failed to run git pull")?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!("{}", summarize_git_pull_failure(&output.stderr));
}
}
fn is_inside_git_repo(path: &std::path::Path) -> bool {
let mut dir = if path.is_dir() {
Some(path)
} else {
path.parent()
};
while let Some(d) = dir {
if d.join(".git").exists() {
return true;
}
dir = d.parent();
}
false
}
pub fn fetch_latest_release_blocking() -> Result<GitHubRelease> {
let url = format!(
"https://api.github.com/repos/{}/releases/latest",
GITHUB_REPO
);
let client = reqwest::blocking::Client::builder()
.timeout(UPDATE_CHECK_TIMEOUT)
.user_agent("jcode-updater")
.build()?;
let response = client
.get(&url)
.send()
.context("Failed to fetch release info")?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("No releases found");
}
if !response.status().is_success() {
anyhow::bail!("GitHub API error: {}", response.status());
}
let release: GitHubRelease = response.json().context("Failed to parse release info")?;
Ok(release)
}
fn latest_main_sha_blocking() -> Result<String> {
let url = format!("https://api.github.com/repos/{}/commits/main", GITHUB_REPO);
let client = reqwest::blocking::Client::builder()
.timeout(UPDATE_CHECK_TIMEOUT)
.user_agent("jcode-updater")
.build()?;
let response = client
.get(&url)
.send()
.context("Failed to check main branch")?;
if !response.status().is_success() {
anyhow::bail!("GitHub API error checking main: {}", response.status());
}
let commit: serde_json::Value = response.json().context("Failed to parse commit info")?;
Ok(commit["sha"]
.as_str()
.unwrap_or("")
.get(..7)
.unwrap_or("")
.to_string())
}
fn platform_asset(release: &GitHubRelease) -> Result<&GitHubAsset> {
let asset_name = get_asset_name();
release
.assets
.iter()
.find(|a| a.name.starts_with(asset_name))
.ok_or_else(|| anyhow::anyhow!("No asset found for platform: {}", asset_name))
}
fn checksum_asset(release: &GitHubRelease) -> Option<&GitHubAsset> {
release.assets.iter().find(|a| a.name == "SHA256SUMS")
}
fn verify_asset_checksum_if_available(
client: &reqwest::blocking::Client,
release: &GitHubRelease,
asset: &GitHubAsset,
bytes: &[u8],
) -> Result<()> {
let Some(checksum_asset) = checksum_asset(release) else {
crate::logging::info(&format!(
"Release {} does not include SHA256SUMS; skipping checksum verification",
release.tag_name
));
return Ok(());
};
let response = client
.get(&checksum_asset.browser_download_url)
.send()
.context("Failed to download SHA256SUMS")?;
if !response.status().is_success() {
anyhow::bail!("SHA256SUMS download failed: {}", response.status());
}
let contents = response.text().context("Failed to read SHA256SUMS")?;
verify_asset_checksum_text(&contents, &asset.name, bytes)?;
crate::logging::info(&format!("Verified SHA256 checksum for {}", asset.name));
Ok(())
}
fn synthetic_main_release(latest_sha: &str) -> GitHubRelease {
GitHubRelease {
tag_name: format!("main-{}", latest_sha),
_name: Some(format!("Built from main ({})", latest_sha)),
_html_url: format!("https://github.com/{}/commit/{}", GITHUB_REPO, latest_sha),
_published_at: None,
assets: vec![],
_target_commitish: latest_sha.to_string(),
}
}
fn install_main_source_update_blocking(latest_sha: &str) -> Result<PathBuf> {
let path = build_from_source()?;
crate::logging::info(&format!(
"Main channel: built successfully at {}",
path.display()
));
let mut metadata = UpdateMetadata::load().unwrap_or_default();
let channel_version = format!("main-{}", latest_sha);
build::install_binary_at_version(&path, &channel_version)
.context("Failed to install built binary")?;
// Carry the long-lived daemon's reload target forward too, but only when it
// was tracking stable. A deliberately-promoted self-dev shared-server build
// is left untouched so the update never silently wipes it out.
if let Err(error) = build::advance_shared_server_if_tracking_stable(&channel_version) {
crate::logging::warn(&format!(
"update: failed to advance shared-server channel to {}: {}",
channel_version, error
));
}
build::update_stable_symlink(&channel_version)?;
build::update_current_symlink(&channel_version)?;
build::update_launcher_symlink_to_current()?;
metadata.installed_version = Some(channel_version.clone());
metadata.installed_from = Some("source".to_string());
metadata.last_check = SystemTime::now();
metadata.save()?;
Ok(path)
}
fn prepare_stable_update_blocking() -> Result<PreparedUpdate> {
let current_version = jcode_build_meta::VERSION;
let current_update_version = current_update_semver();
let release = fetch_latest_release_blocking()?;
let release_version = release.tag_name.trim_start_matches('v');
if release_version == current_update_version.trim_start_matches('v')
|| !version_is_newer(
release_version,
current_update_version.trim_start_matches('v'),
)
{
return Ok(PreparedUpdate::None {
current: current_version.to_string(),
});
}
let Ok(asset) = platform_asset(&release) else {
return Ok(PreparedUpdate::None {
current: current_version.to_string(),
});
};
let metadata = UpdateMetadata::load().unwrap_or_default();
let duration = estimate_release_update_duration(asset._size, metadata.last_release_update_secs);
let size_mb = asset._size as f64 / (1024.0 * 1024.0);
let summary = format!(
"Prebuilt update {}{} (~{:.0} MB, {}). {}",
current_version,
release.tag_name,
size_mb,
format_duration_estimate(duration),
if duration >= BACKGROUND_UPDATE_THRESHOLD {
"Running in the background and will reload when it is ready."
} else {
"This should be quick."
}
);
Ok(PreparedUpdate::Stable {
release,
estimate: update_estimate(summary, duration),
})
}
fn prepare_main_update_blocking() -> Result<PreparedUpdate> {
let current_hash = jcode_build_meta::GIT_HASH;
if current_hash.is_empty() || current_hash == "unknown" {
crate::logging::info("Main channel: no git hash in binary, skipping update check");
return Ok(PreparedUpdate::None {
current: jcode_build_meta::VERSION.to_string(),
});
}
let latest_sha = latest_main_sha_blocking()?;
if latest_sha.is_empty() {
return Ok(PreparedUpdate::None {
current: current_hash.to_string(),
});
}
let current_short = if current_hash.len() >= 7 {
&current_hash[..7]
} else {
current_hash
};
if current_short == latest_sha {
crate::logging::info(&format!("Main channel: up to date ({})", current_short));
return Ok(PreparedUpdate::None {
current: format!("main-{}", current_short),
});
}
crate::logging::info(&format!(
"Main channel: new commit {} -> {}",
current_short, latest_sha
));
if has_cargo() {
let repo_dir = source_build_repo_dir()?;
let repo_exists = repo_dir.join(".git").exists();
let has_previous_build = build::release_binary_path(&repo_dir).exists();
let metadata = UpdateMetadata::load().unwrap_or_default();
let duration = estimate_source_update_duration(
repo_exists,
has_previous_build,
metadata.last_source_update_secs,
);
let action = if repo_exists {
if has_previous_build {
"git pull + cargo build with a warm build cache"
} else {
"git pull + cargo build"
}
} else {
"initial clone + cargo build"
};
let summary = format!(
"Source update {} → main-{} requires {} ({}). Running in the background and will reload when it is ready.",
current_short,
latest_sha,
action,
format_duration_estimate(duration)
);
return Ok(PreparedUpdate::MainSource {
latest_sha,
estimate: update_estimate(summary, duration),
});
}
crate::logging::info("Main channel: cargo not found, falling back to latest release");
prepare_stable_update_blocking()
}
pub fn prepare_update_blocking() -> Result<PreparedUpdate> {
let channel = crate::config::config().features.update_channel;
match channel {
crate::config::UpdateChannel::Main => prepare_main_update_blocking(),
crate::config::UpdateChannel::Stable => prepare_stable_update_blocking(),
}
}
pub fn spawn_background_session_update(session_id: String) {
std::thread::spawn(move || {
use crate::bus::{Bus, BusEvent, ClientMaintenanceAction, SessionUpdateStatus};
let action = ClientMaintenanceAction::Update;
let publish = |status| Bus::global().publish(BusEvent::SessionUpdateStatus(status));
match prepare_update_blocking() {
Ok(PreparedUpdate::None { current }) => {
publish(SessionUpdateStatus::NoUpdate {
session_id,
current,
});
}
Ok(PreparedUpdate::Stable { release, estimate }) => {
publish(SessionUpdateStatus::Status {
session_id: session_id.clone(),
action,
message: estimate.summary,
});
publish(SessionUpdateStatus::Status {
session_id: session_id.clone(),
action,
message: format!(
"Downloading {} (estimated {})...",
release.tag_name,
format_duration_estimate(estimate.duration)
),
});
let progress_session_id = session_id.clone();
let progress_version = release.tag_name.clone();
match download_and_install_blocking_with_progress(&release, |progress| {
publish(SessionUpdateStatus::Status {
session_id: progress_session_id.clone(),
action,
message: format!(
"{} {}",
progress_version,
format_download_progress_bar(progress)
),
});
}) {
Ok(_) => publish(SessionUpdateStatus::ReadyToReload {
session_id,
action,
version: release.tag_name,
}),
Err(error) => publish(SessionUpdateStatus::Error {
session_id,
action,
message: format!("Update failed: {}", error),
}),
}
}
Ok(PreparedUpdate::MainSource {
latest_sha,
estimate,
}) => {
publish(SessionUpdateStatus::Status {
session_id: session_id.clone(),
action,
message: estimate.summary,
});
publish(SessionUpdateStatus::Status {
session_id: session_id.clone(),
action,
message: format!(
"Building main-{} in the background (estimated {})...",
latest_sha,
format_duration_estimate(estimate.duration)
),
});
match install_main_source_update_blocking(&latest_sha) {
Ok(_) => publish(SessionUpdateStatus::ReadyToReload {
session_id,
action,
version: format!("main-{}", latest_sha),
}),
Err(error) => publish(SessionUpdateStatus::Error {
session_id,
action,
message: format!("Update failed: {}", error),
}),
}
}
Err(error) => publish(SessionUpdateStatus::Error {
session_id,
action,
message: format!("Update check failed: {}", error),
}),
}
});
}
pub fn check_for_update_blocking() -> Result<Option<GitHubRelease>> {
let channel = crate::config::config().features.update_channel;
match channel {
crate::config::UpdateChannel::Main => check_for_main_update_blocking(),
crate::config::UpdateChannel::Stable => check_for_stable_update_blocking(),
}
}
fn check_for_stable_update_blocking() -> Result<Option<GitHubRelease>> {
let current_version = current_update_semver();
let release = fetch_latest_release_blocking()?;
let release_version = release.tag_name.trim_start_matches('v');
if release_version == current_version.trim_start_matches('v') {
return Ok(None);
}
if version_is_newer(release_version, current_version.trim_start_matches('v')) {
let asset_name = get_asset_name();
let has_asset = release
.assets
.iter()
.any(|a| a.name.starts_with(asset_name));
if has_asset {
return Ok(Some(release));
}
}
Ok(None)
}
/// Check for updates on the main branch (cutting edge channel).
/// Compares the current binary's git hash against the latest commit on main.
/// If a new commit is found:
/// - Tries to build from source if cargo is available
/// - Falls back to latest GitHub Release if not
fn check_for_main_update_blocking() -> Result<Option<GitHubRelease>> {
let current_hash = jcode_build_meta::GIT_HASH;
if current_hash.is_empty() || current_hash == "unknown" {
crate::logging::info("Main channel: no git hash in binary, skipping update check");
return Ok(None);
}
let latest_sha = latest_main_sha_blocking()?;
if latest_sha.is_empty() {
return Ok(None);
}
// Compare short hashes
let current_short = if current_hash.len() >= 7 {
&current_hash[..7]
} else {
current_hash
};
if current_short == latest_sha {
crate::logging::info(&format!("Main channel: up to date ({})", current_short));
return Ok(None);
}
crate::logging::info(&format!(
"Main channel: new commit {} -> {}",
current_short, latest_sha
));
// Try to build from source
if has_cargo() {
crate::logging::info("Main channel: cargo found, attempting build from source");
match install_main_source_update_blocking(&latest_sha) {
Ok(_) => {
return Ok(Some(synthetic_main_release(&latest_sha)));
}
Err(e) => {
crate::logging::error(&format!("Main channel: build failed: {}", e));
// Fall through to release fallback
}
}
} else {
crate::logging::info("Main channel: cargo not found, falling back to latest release");
}
// Fallback: use latest stable release if available
if let Ok(release) = fetch_latest_release_blocking() {
let asset_name = get_asset_name();
let has_asset = release
.assets
.iter()
.any(|a| a.name.starts_with(asset_name));
if has_asset {
let release_version = release.tag_name.trim_start_matches('v');
let current_version = current_update_semver().trim_start_matches('v');
if version_is_newer(release_version, current_version) {
return Ok(Some(release));
}
}
}
Ok(None)
}
/// Check if cargo is available on the system
fn has_cargo() -> bool {
std::process::Command::new("cargo")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Build jcode from source by cloning/pulling the repo and running cargo build
fn build_from_source() -> Result<PathBuf> {
let started = Instant::now();
let build_dir = source_build_root()?;
fs::create_dir_all(&build_dir)?;
let repo_dir = build_dir.join("jcode");
if repo_dir.join(".git").exists() {
// Pull latest
crate::logging::info("Main channel: pulling latest from main...");
let output = std::process::Command::new("git")
.args(["pull", "--ff-only", "origin", "main"])
.current_dir(&repo_dir)
.output()
.context("Failed to run git pull")?;
if !output.status.success() {
// If pull fails (e.g. diverged), reset to origin/main
let summary = summarize_git_pull_failure(&output.stderr);
crate::logging::warn(&format!("{}, trying reset", summary));
let output = std::process::Command::new("git")
.args(["fetch", "origin", "main"])
.current_dir(&repo_dir)
.output()
.context("Failed to run git fetch")?;
if !output.status.success() {
anyhow::bail!(
"git fetch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let output = std::process::Command::new("git")
.args(["reset", "--hard", "origin/main"])
.current_dir(&repo_dir)
.output()
.context("Failed to run git reset")?;
if !output.status.success() {
anyhow::bail!(
"git reset failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
} else {
// Clone
crate::logging::info("Main channel: cloning repository...");
let clone_url = format!("https://github.com/{}.git", GITHUB_REPO);
let output = std::process::Command::new("git")
.args([
"clone", "--depth", "1", "--branch", "main", &clone_url, "jcode",
])
.current_dir(&build_dir)
.output()
.context("Failed to run git clone")?;
if !output.status.success() {
anyhow::bail!(
"git clone failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
// Build
crate::logging::info("Main channel: building with cargo...");
let output = std::process::Command::new("cargo")
.args(["build", "--release"])
.current_dir(&repo_dir)
.env("JCODE_RELEASE_BUILD", "1")
.output()
.context("Failed to run cargo build")?;
if !output.status.success() {
anyhow::bail!(
"cargo build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let binary = build::release_binary_path(&repo_dir);
if !binary.exists() {
anyhow::bail!("Built binary not found at {}", binary.display());
}
record_source_update_duration(started.elapsed());
Ok(binary)
}
pub fn download_and_install_blocking(release: &GitHubRelease) -> Result<PathBuf> {
download_and_install_blocking_with_progress(release, |_| {})
}
/// Download an asset into memory, retrying with HTTP Range resume so a slow or
/// flaky connection recovers instead of restarting from zero.
///
/// Returns the full asset bytes plus the best-known total size (for callers
/// that want a final size). Progress callbacks are invoked across retries using
/// the cumulative bytes already on disk, so the UI never appears to go
/// backwards when a stalled connection reconnects.
fn download_asset_with_resume(
client: &reqwest::blocking::Client,
download_url: &str,
total_hint: Option<u64>,
on_progress: &mut impl FnMut(DownloadProgress),
) -> Result<(Vec<u8>, Option<u64>)> {
let mut bytes: Vec<u8> =
Vec::with_capacity(total_hint.unwrap_or_default().min(usize::MAX as u64) as usize);
let mut total = total_hint;
let mut next_progress_at = 0_u64;
let mut last_error: Option<anyhow::Error> = None;
// Count only *consecutive stalls* (attempts that made no forward progress).
// A slow-but-advancing download resets this and keeps resuming, so it can
// take as long as it needs; only a genuinely stuck connection gives up.
let mut stalls = 0_usize;
let mut attempt = 0_usize;
on_progress(DownloadProgress {
downloaded: 0,
total,
});
while stalls < DOWNLOAD_MAX_ATTEMPTS {
attempt += 1;
let resume_from = bytes.len() as u64;
let mut request = client.get(download_url);
if resume_from > 0 {
// Ask the server to continue where we left off.
request = request.header(reqwest::header::RANGE, format!("bytes={}-", resume_from));
}
let response = match request.send() {
Ok(response) => response,
Err(err) => {
last_error = Some(anyhow::anyhow!("Failed to download update: {}", err));
log_download_retry(attempt, resume_from, &err);
stalls += 1;
continue;
}
};
let status = response.status();
if resume_from > 0 && status == reqwest::StatusCode::OK {
// Server ignored the Range header and is resending from the start;
// discard the partial buffer so we don't corrupt the result.
bytes.clear();
next_progress_at = 0;
} else if resume_from > 0 && status != reqwest::StatusCode::PARTIAL_CONTENT {
last_error = Some(anyhow::anyhow!(
"Resume request returned unexpected status {}",
status
));
log_download_retry_status(attempt, resume_from, status);
stalls += 1;
continue;
} else if !status.is_success() {
last_error = Some(anyhow::anyhow!("Download failed: {}", status));
log_download_retry_status(attempt, resume_from, status);
stalls += 1;
continue;
}
// Establish the total size. For a 206 response, content-length is the
// remaining bytes, so prefer the original hint / Content-Range total.
if total.is_none() {
total = content_range_total(&response).or_else(|| {
if status == reqwest::StatusCode::PARTIAL_CONTENT {
response
.content_length()
.map(|len| len.saturating_add(bytes.len() as u64))
} else {
response.content_length()
}
});
}
let before = bytes.len() as u64;
let read_result = read_response_into(
response,
&mut bytes,
&mut next_progress_at,
total,
on_progress,
);
let made_progress = bytes.len() as u64 > before;
match read_result {
Ok(()) => {
let downloaded = bytes.len() as u64;
// If we know the total and fell short, treat as a stall and retry.
if let Some(total) = total
&& downloaded < total
{
last_error = Some(anyhow::anyhow!(
"Download ended early ({} of {} bytes)",
downloaded,
total
));
log_download_retry_short(attempt, downloaded, total);
stalls = if made_progress { 0 } else { stalls + 1 };
continue;
}
on_progress(DownloadProgress { downloaded, total });
return Ok((bytes, total));
}
Err(err) => {
let downloaded = bytes.len() as u64;
crate::logging::warn(&format!(
"Update download attempt {} stream error at {} bytes: {}; retrying with resume",
attempt, downloaded, err
));
last_error = Some(err);
stalls = if made_progress { 0 } else { stalls + 1 };
continue;
}
}
}
Err(last_error.unwrap_or_else(|| {
anyhow::anyhow!(
"Download stalled with no progress after {} attempts",
DOWNLOAD_MAX_ATTEMPTS
)
}))
}
fn read_response_into(
mut response: reqwest::blocking::Response,
bytes: &mut Vec<u8>,
next_progress_at: &mut u64,
total: Option<u64>,
on_progress: &mut impl FnMut(DownloadProgress),
) -> Result<()> {
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = response
.read(&mut buffer)
.context("Failed to read download")?;
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
let downloaded = bytes.len() as u64;
if downloaded >= *next_progress_at || total.is_some_and(|total| downloaded >= total) {
on_progress(DownloadProgress { downloaded, total });
*next_progress_at = downloaded.saturating_add(DOWNLOAD_PROGRESS_UPDATE_STEP);
}
}
Ok(())
}
fn content_range_total(response: &reqwest::blocking::Response) -> Option<u64> {
// Content-Range: bytes 200-1023/1024
response
.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.rsplit('/').next())
.and_then(|total| total.trim().parse::<u64>().ok())
}
fn log_download_retry(attempt: usize, resume_from: u64, err: &impl std::fmt::Display) {
crate::logging::warn(&format!(
"Update download attempt {}/{} failed at {} bytes: {}; retrying with resume",
attempt, DOWNLOAD_MAX_ATTEMPTS, resume_from, err
));
}
fn log_download_retry_status(attempt: usize, resume_from: u64, status: reqwest::StatusCode) {
crate::logging::warn(&format!(
"Update download attempt {}/{} got status {} at {} bytes; retrying with resume",
attempt, DOWNLOAD_MAX_ATTEMPTS, status, resume_from
));
}
fn log_download_retry_short(attempt: usize, downloaded: u64, total: u64) {
crate::logging::warn(&format!(
"Update download attempt {}/{} ended early ({} of {} bytes); retrying with resume",
attempt, DOWNLOAD_MAX_ATTEMPTS, downloaded, total
));
}
pub fn download_and_install_blocking_with_progress(
release: &GitHubRelease,
mut on_progress: impl FnMut(DownloadProgress),
) -> Result<PathBuf> {
let started = Instant::now();
let asset_name = get_asset_name();
let asset = release
.assets
.iter()
.find(|a| a.name.starts_with(asset_name))
.ok_or_else(|| anyhow::anyhow!("No asset found for platform: {}", asset_name))?;
let download_url = asset.browser_download_url.clone();
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("jcode-update-{}", std::process::id()));
// The `timeout` here applies per request. Since each retry below is a
// separate request, this acts as a *per-attempt* budget rather than a cap
// on the whole asset: a slow-but-progressing download resumes via HTTP
// Range across attempts (up to DOWNLOAD_MAX_ATTEMPTS), so it can complete
// even when a single attempt would not finish in time. A genuinely hung
// connection is still bounded by the per-attempt timeout.
let client = reqwest::blocking::Client::builder()
.connect_timeout(DOWNLOAD_CONNECT_TIMEOUT)
.timeout(DOWNLOAD_ATTEMPT_TIMEOUT)
.user_agent("jcode-updater")
.build()?;
let total_hint = if asset._size > 0 {
Some(asset._size)
} else {
None
};
let (bytes, _total) =
download_asset_with_resume(&client, &download_url, total_hint, &mut on_progress)?;
verify_asset_checksum_if_available(&client, release, asset, &bytes)?;
let mut installed_version_dir: Option<PathBuf> = None;
if asset.name.ends_with(".tar.gz") {
let cursor = std::io::Cursor::new(&bytes);
let gz = flate2::read::GzDecoder::new(cursor);
let mut archive = tar::Archive::new(gz);
let extract_dir = temp_path.with_extension("extract");
if extract_dir.exists() {
let _ = fs::remove_dir_all(&extract_dir);
}
fs::create_dir_all(&extract_dir).context("Failed to create archive extraction dir")?;
let mut extracted_binary: Option<PathBuf> = None;
for entry in archive.entries()? {
let mut entry = entry?;
let entry_path = entry.path()?.into_owned();
if entry_path.components().count() != 1 {
continue;
}
let file_name = entry_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if file_name.is_empty() || file_name.ends_with(".tar.gz") {
continue;
}
let dest = extract_dir.join(&file_name);
entry.unpack(&dest)?;
if file_name.starts_with("jcode") && !file_name.ends_with(".bin") {
extracted_binary = Some(dest);
}
}
let Some(extracted_binary) = extracted_binary else {
anyhow::bail!("Could not find jcode binary inside tar.gz archive");
};
crate::platform::set_permissions_executable(&extracted_binary)?;
let version = release.tag_name.trim_start_matches('v');
let dest_dir = build::builds_dir()?.join("versions").join(version);
fs::create_dir_all(&dest_dir).context("Failed to create version install dir")?;
let mut installed_files = Vec::new();
for entry in fs::read_dir(&extract_dir).context("Failed to read extracted archive")? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let name = entry.file_name();
let name_string = name.to_string_lossy();
let dest_name = if name_string == get_asset_name()
|| name_string == format!("{}.exe", get_asset_name())
{
build::binary_name().to_string()
} else {
name_string.to_string()
};
let dest = dest_dir.join(dest_name);
if dest.exists() {
fs::remove_file(&dest)?;
}
fs::copy(entry.path(), &dest)
.with_context(|| format!("Failed to install {}", dest.display()))?;
if dest
.file_name()
.is_some_and(|name| name == build::binary_name())
|| dest.extension().is_some_and(|ext| ext == "bin")
{
crate::platform::set_permissions_executable(&dest)?;
}
installed_files.push(dest);
}
// Give every installed file the same mtime. The wrapper script and the
// `.bin` payload otherwise land with whatever sub-second skew the copy
// loop produced, and any code comparing binary freshness by mtime then
// sees two "different age" files for one logical install.
let install_stamp = SystemTime::now();
for path in &installed_files {
if let Ok(file) = fs::File::options().write(true).open(path) {
let _ = file.set_modified(install_stamp);
}
}
let _ = fs::remove_dir_all(&extract_dir);
installed_version_dir = Some(dest_dir.join(build::binary_name()));
} else {
fs::write(&temp_path, &bytes).context("Failed to write temp file")?;
}
let version = release.tag_name.trim_start_matches('v');
let mut metadata = UpdateMetadata::load().unwrap_or_default();
let versioned_path = if let Some(versioned_path) = installed_version_dir {
versioned_path
} else {
crate::platform::set_permissions_executable(&temp_path)?;
let versioned_path = build::install_binary_at_version(&temp_path, version)?;
let _ = fs::remove_file(&temp_path);
versioned_path
};
if let Err(error) = build::advance_shared_server_if_tracking_stable(version) {
crate::logging::warn(&format!(
"update: failed to advance shared-server channel to {}: {}",
version, error
));
}
build::update_stable_symlink(version)?;
build::update_current_symlink(version)?;
build::update_launcher_symlink_to_current()?;
metadata.installed_version = Some(release.tag_name.clone());
metadata.installed_from = Some(asset.browser_download_url.clone());
metadata.last_check = SystemTime::now();
metadata.save()?;
record_release_update_duration(started.elapsed());
Ok(versioned_path)
}
pub fn check_and_maybe_update(auto_install: bool) -> UpdateCheckResult {
use crate::bus::{Bus, BusEvent, UpdateStatus};
if !should_auto_update() {
return UpdateCheckResult::NoUpdate;
}
let metadata = UpdateMetadata::load().unwrap_or_default();
if !metadata.should_check() {
return UpdateCheckResult::NoUpdate;
}
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Checking));
match check_for_update_blocking() {
Ok(Some(release)) => {
let current = jcode_build_meta::VERSION.to_string();
let latest = release.tag_name.clone();
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Available {
current: current.clone(),
latest: latest.clone(),
}));
if auto_install {
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Downloading {
version: latest.clone(),
}));
match download_and_install_blocking(&release) {
Ok(path) => {
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Installed {
version: latest.clone(),
}));
UpdateCheckResult::UpdateInstalled {
version: latest,
path,
}
}
Err(e) => {
let msg = format!("Failed to install: {}", e);
Bus::global()
.publish(BusEvent::UpdateStatus(UpdateStatus::Error(msg.clone())));
UpdateCheckResult::Error(msg)
}
}
} else {
let mut metadata = UpdateMetadata::load().unwrap_or_default();
metadata.last_check = SystemTime::now();
let _ = metadata.save();
UpdateCheckResult::UpdateAvailable {
current,
latest,
_release: release,
}
}
}
Ok(None) => {
repair_stale_shared_server_after_no_update();
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::UpToDate));
let mut metadata = UpdateMetadata::load().unwrap_or_default();
metadata.last_check = SystemTime::now();
let _ = metadata.save();
UpdateCheckResult::NoUpdate
}
Err(e) => {
let msg = format!("Check failed: {}", e);
Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::Error(msg.clone())));
UpdateCheckResult::Error(msg)
}
}
}
fn repair_stale_shared_server_after_no_update() {
match build::repair_stale_shared_server_channel() {
Ok(build::SharedServerRepair::Repaired {
previous,
repaired_to,
}) => {
crate::logging::info(&format!(
"update: repaired stale shared-server channel {:?} -> {} after no-op update check",
previous, repaired_to
));
}
Ok(build::SharedServerRepair::AlreadyCurrent) => {}
Err(error) => {
crate::logging::warn(&format!(
"update: failed to repair stale shared-server channel after no-op update check: {}",
error
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use jcode_update_core::parse_sha256sums;
use sha2::{Digest, Sha256};
#[test]
fn test_version_is_newer() {
assert!(version_is_newer("0.1.3", "0.1.2"));
assert!(version_is_newer("0.2.0", "0.1.9"));
assert!(version_is_newer("1.0.0", "0.9.9"));
assert!(!version_is_newer("0.1.2", "0.1.2"));
assert!(!version_is_newer("0.1.1", "0.1.2"));
assert!(!version_is_newer("0.0.9", "0.1.0"));
}
#[test]
fn test_asset_name() {
let name = get_asset_name();
assert!(name.starts_with("jcode-"));
}
#[test]
fn test_format_download_progress_bar_known_total() {
let rendered = format_download_progress_bar(DownloadProgress {
downloaded: 512,
total: Some(1024),
});
assert!(rendered.contains("50%"));
assert!(rendered.contains("512 B/1.0 KiB"));
assert!(rendered.contains('█'));
assert!(rendered.contains('░'));
}
#[test]
fn test_format_download_progress_bar_unknown_total() {
let rendered = format_download_progress_bar(DownloadProgress {
downloaded: 2 * 1024 * 1024,
total: None,
});
assert_eq!(rendered, "Downloading update... 2.0 MiB downloaded");
}
#[test]
fn test_parse_sha256sums_accepts_standard_and_binary_lines() {
let digest_a = "a".repeat(64);
let digest_b = "B".repeat(64);
let digest_b_lower = "b".repeat(64);
let contents = format!(
"# generated by release workflow\n{} jcode-linux-x86_64.tar.gz\r\n{} *jcode-windows-x86_64.exe\n",
digest_a, digest_b
);
let parsed = parse_sha256sums(&contents).unwrap();
assert_eq!(
parsed.get("jcode-linux-x86_64.tar.gz").map(String::as_str),
Some(digest_a.as_str())
);
assert_eq!(
parsed.get("jcode-windows-x86_64.exe").map(String::as_str),
Some(digest_b_lower.as_str())
);
}
#[test]
fn test_verify_asset_checksum_text_accepts_matching_digest() {
let bytes = b"hello update";
let digest = format!("{:x}", Sha256::digest(bytes));
let contents = format!("{} jcode-linux-x86_64.tar.gz\n", digest);
verify_asset_checksum_text(&contents, "jcode-linux-x86_64.tar.gz", bytes).unwrap();
}
#[test]
fn test_verify_asset_checksum_text_rejects_mismatch() {
let wrong = "0".repeat(64);
let contents = format!("{} jcode-linux-x86_64.tar.gz\n", wrong);
let err = verify_asset_checksum_text(&contents, "jcode-linux-x86_64.tar.gz", b"actual")
.unwrap_err()
.to_string();
assert!(err.contains("Checksum mismatch"));
}
#[test]
fn test_verify_asset_checksum_text_requires_asset_entry() {
let digest = "1".repeat(64);
let contents = format!("{} other-asset.tar.gz\n", digest);
let err = verify_asset_checksum_text(&contents, "jcode-linux-x86_64.tar.gz", b"actual")
.unwrap_err()
.to_string();
assert!(err.contains("does not list"));
}
#[test]
fn test_parse_sha256sums_rejects_invalid_digest() {
let err = parse_sha256sums("not-a-sha jcode-linux-x86_64.tar.gz\n")
.unwrap_err()
.to_string();
assert!(err.contains("invalid SHA256 digest"));
}
#[test]
fn test_is_release_build() {
assert!(!is_release_build());
}
#[test]
fn test_should_auto_update_dev_build() {
assert!(!should_auto_update());
}
#[test]
fn test_summarize_git_pull_failure_diverged() {
let stderr = b"hint: You have divergent branches and need to specify how to reconcile them.\nfatal: Need to specify how to reconcile divergent branches.\n";
assert_eq!(
summarize_git_pull_failure(stderr),
jcode_update_core::GIT_PULL_DIVERGED_SUMMARY
);
assert!(jcode_update_core::summary_is_divergence(
&summarize_git_pull_failure(stderr)
));
}
#[test]
fn test_summarize_git_pull_failure_no_tracking_branch() {
let stderr = b"There is no tracking information for the current branch.\n";
assert_eq!(
summarize_git_pull_failure(stderr),
"git pull failed: current branch has no upstream tracking branch"
);
}
#[test]
fn test_summarize_git_pull_failure_uses_first_non_hint_line() {
let stderr = b"hint: test hint\nfatal: repository not found\n";
assert_eq!(
summarize_git_pull_failure(stderr),
"git pull failed: repository not found"
);
}
#[test]
fn test_estimate_release_update_duration_uses_size_buckets() {
assert_eq!(
estimate_release_update_duration(10 * 1024 * 1024, None),
Duration::from_secs(10)
);
assert_eq!(
estimate_release_update_duration(40 * 1024 * 1024, None),
Duration::from_secs(35)
);
}
#[test]
fn test_estimate_source_update_duration_prefers_history() {
assert_eq!(
estimate_source_update_duration(true, true, Some(123.4)),
Duration::from_secs(123)
);
}
#[test]
fn test_content_range_total_parses_total() {
use reqwest::header::{HeaderMap, HeaderValue};
// Build a Response is awkward; test the parser via a header map directly.
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_RANGE,
HeaderValue::from_static("bytes 200-1023/1024"),
);
let parsed = headers
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.rsplit('/').next())
.and_then(|total| total.trim().parse::<u64>().ok());
assert_eq!(parsed, Some(1024));
}
#[test]
fn test_content_range_total_unknown_size_is_none() {
use reqwest::header::{HeaderMap, HeaderValue};
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::CONTENT_RANGE,
HeaderValue::from_static("bytes 200-1023/*"),
);
let parsed = headers
.get(reqwest::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.rsplit('/').next())
.and_then(|total| total.trim().parse::<u64>().ok());
assert_eq!(parsed, None);
}
/// End-to-end resume test: a tiny HTTP server serves the first half of the
/// body then drops the connection, and on the resumed Range request serves
/// the rest. The download must recover and return the full payload.
#[test]
fn test_download_asset_with_resume_recovers_from_dropped_connection() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let payload: Vec<u8> = (0..2000u32).map(|i| (i % 251) as u8).collect();
let total = payload.len();
let split = total / 2;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let payload_for_server = payload.clone();
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_server = Arc::clone(&request_count);
let handle = std::thread::spawn(move || {
// Serve exactly two connections: first truncated, second resumed.
for _ in 0..2 {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let n = request_count_server.fetch_add(1, Ordering::SeqCst);
// Parse request headers; look for a Range header.
let mut range_start = 0usize;
{
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
let trimmed = line.trim_end();
if let Some(rest) =
trimmed.to_ascii_lowercase().strip_prefix("range: bytes=")
&& let Some(start) = rest.split('-').next()
{
range_start = start.trim().parse().unwrap_or(0);
}
if trimmed.is_empty() {
break;
}
}
}
if n == 0 {
// First attempt: 200 OK, but only send the first half, then
// close mid-stream to simulate a stalled/dropped connection.
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n",
total
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(&payload_for_server[..split]);
let _ = stream.flush();
// Drop connection without finishing the body.
} else {
// Resumed attempt: serve 206 with the remaining bytes.
let remaining = &payload_for_server[range_start..];
let header = format!(
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n\r\n",
remaining.len(),
range_start,
total - 1,
total
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(remaining);
let _ = stream.flush();
}
}
});
let client = reqwest::blocking::Client::builder()
.build()
.expect("client");
let url = format!("http://{}/asset", addr);
let (bytes, parsed_total) =
download_asset_with_resume(&client, &url, Some(total as u64), &mut |_| {})
.expect("download should recover");
handle.join().ok();
assert_eq!(bytes, payload, "resumed download must reconstruct payload");
assert_eq!(parsed_total, Some(total as u64));
assert_eq!(
request_count.load(Ordering::SeqCst),
2,
"should have made an initial + one resume request"
);
}
/// A connection that keeps dropping but always advances a little must still
/// complete: forward progress resets the consecutive-stall budget, so the
/// number of resumes can exceed DOWNLOAD_MAX_ATTEMPTS as long as each one
/// delivers new bytes.
#[test]
fn test_download_asset_with_resume_tolerates_many_progressing_drops() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let payload: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
let total = payload.len();
// Each attempt delivers only this many bytes, then drops, so it takes
// many more than DOWNLOAD_MAX_ATTEMPTS attempts to finish.
let chunk = 100usize;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let payload_for_server = payload.clone();
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_server = Arc::clone(&request_count);
let handle = std::thread::spawn(move || {
loop {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let n = request_count_server.fetch_add(1, Ordering::SeqCst);
let mut range_start = 0usize;
{
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
let trimmed = line.trim_end();
if let Some(rest) =
trimmed.to_ascii_lowercase().strip_prefix("range: bytes=")
&& let Some(start) = rest.split('-').next()
{
range_start = start.trim().parse().unwrap_or(0);
}
if trimmed.is_empty() {
break;
}
}
}
let end = (range_start + chunk).min(total);
let body = &payload_for_server[range_start..end];
let header = if n == 0 {
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n",
total
)
} else {
format!(
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n\r\n",
total - range_start,
range_start,
total - 1,
total
)
};
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
// Drop after a partial chunk unless we've reached the end.
if end >= total {
break;
}
}
});
let client = reqwest::blocking::Client::builder()
.build()
.expect("client");
let url = format!("http://{}/asset", addr);
let (bytes, _total) =
download_asset_with_resume(&client, &url, Some(total as u64), &mut |_| {})
.expect("progressing download should complete");
handle.join().ok();
assert_eq!(bytes, payload);
assert!(
request_count.load(Ordering::SeqCst) > DOWNLOAD_MAX_ATTEMPTS,
"test should require more than the stall budget of resumes"
);
}
/// Mirrors the real #293 shape closely: the caller has *no* size hint
/// (None), a slow connection drops repeatedly, and the size is learned from
/// the server (Content-Length on the initial 200, Content-Range on the 206
/// resumes, exactly like a GitHub release asset). The download must still
/// complete, learn the correct total, and report progress that never moves
/// backwards across reconnects (so the UI bar can't appear to regress).
#[test]
fn test_download_asset_with_resume_unknown_total_and_monotonic_progress() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let payload: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let total = payload.len();
let chunk = 400usize;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let payload_for_server = payload.clone();
let request_count = Arc::new(AtomicUsize::new(0));
let request_count_server = Arc::clone(&request_count);
let handle = std::thread::spawn(move || {
loop {
let Ok((mut stream, _)) = listener.accept() else {
break;
};
let n = request_count_server.fetch_add(1, Ordering::SeqCst);
let mut range_start = 0usize;
{
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
let trimmed = line.trim_end();
if let Some(rest) =
trimmed.to_ascii_lowercase().strip_prefix("range: bytes=")
&& let Some(start) = rest.split('-').next()
{
range_start = start.trim().parse().unwrap_or(0);
}
if trimmed.is_empty() {
break;
}
}
}
let end = (range_start + chunk).min(total);
let body = &payload_for_server[range_start..end];
// Like a real GitHub asset: the initial 200 carries the full
// Content-Length (so a mid-stream drop is detectable), and the
// 206 resumes carry Content-Range. The *caller* still gets no
// size hint, so the total must be learned from these headers.
let header = if n == 0 {
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\n\r\n",
total
)
} else {
format!(
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {}-{}/{}\r\n\r\n",
total - range_start,
range_start,
total - 1,
total
)
};
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
// Drop after a partial chunk unless we've reached the end.
if end >= total {
break;
}
}
});
let client = reqwest::blocking::Client::builder()
.build()
.expect("client");
let url = format!("http://{}/asset", addr);
let mut progress_points: Vec<u64> = Vec::new();
let mut seen_total: Option<u64> = None;
let (bytes, parsed_total) = download_asset_with_resume(
&client,
&url,
None, // no size hint, like a fresh download with no metadata
&mut |p| {
progress_points.push(p.downloaded);
if p.total.is_some() {
seen_total = p.total;
}
},
)
.expect("download with unknown caller hint should complete");
handle.join().ok();
assert_eq!(bytes, payload, "payload must be fully reconstructed");
assert_eq!(
parsed_total,
Some(total as u64),
"total must be learned from the server headers"
);
assert_eq!(seen_total, Some(total as u64));
assert!(
progress_points.windows(2).all(|w| w[1] >= w[0]),
"progress must never go backwards across reconnects: {:?}",
progress_points
);
assert_eq!(
*progress_points.last().expect("at least one progress point"),
total as u64,
"final progress must reach the full size"
);
}
}