release(v1.7.17-alpha): cancel download + stall detection
Add Cancel Download button + stall detection so a wedged download can be recovered instead of leaving the UI stuck on a frozen progress bar. Backend: - update.rs: DOWNLOAD_CANCEL AtomicBool + DOWNLOAD_PROGRESS_AT AtomicU64 - download loop checks cancel between chunks and during retry backoff (500ms slices instead of one exponential sleep, so Cancel wakes fast) - cancel_download() wipes staging + clears update_in_progress - update.status exposes download_progress.stalled (30s no-progress) - RPC: update.cancel-download + dispatcher entry Frontend: - SystemUpdate.vue: Cancel Download button, amber stall styling, stalled copy, cancel-download confirm branch in modal - i18n keys (en + es) for cancel/stall flow - v1.7.17-alpha What's New block in AccountInfoSection Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -420,6 +420,7 @@ impl RpcHandler {
|
||||
"update.status" => self.handle_update_status().await,
|
||||
"update.dismiss" => self.handle_update_dismiss().await,
|
||||
"update.download" => self.handle_update_download().await,
|
||||
"update.cancel-download" => self.handle_update_cancel_download().await,
|
||||
"update.apply" => self.handle_update_apply().await,
|
||||
"update.git-apply" => self.handle_update_git_apply().await,
|
||||
"update.rollback" => self.handle_update_rollback().await,
|
||||
|
||||
@@ -168,6 +168,27 @@ impl RpcHandler {
|
||||
let active = total > 0 && downloaded < total;
|
||||
let completed = total > 0 && downloaded >= total;
|
||||
|
||||
// Stall detection: if the progress-at timestamp hasn't advanced
|
||||
// for 30+ seconds while active, the download is wedged (usually
|
||||
// HTTP stream silently dropped and reqwest is waiting out its
|
||||
// read timeout). The UI uses this to surface a Cancel button
|
||||
// with explanatory copy.
|
||||
let stalled = if active {
|
||||
let last_at = update::DOWNLOAD_PROGRESS_AT
|
||||
.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if last_at > 0 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
now.saturating_sub(last_at) > 30_000
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"current_version": state.current_version,
|
||||
"last_check": state.last_check,
|
||||
@@ -179,6 +200,7 @@ impl RpcHandler {
|
||||
"bytes_downloaded": downloaded,
|
||||
"total_bytes": total,
|
||||
"active": active,
|
||||
"stalled": stalled,
|
||||
}))
|
||||
} else { None },
|
||||
}))
|
||||
@@ -200,6 +222,13 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel an in-flight or stuck download. Clears the live counters
|
||||
/// and staging dir so the UI returns to the "Download Update" state.
|
||||
pub(super) async fn handle_update_cancel_download(&self) -> Result<serde_json::Value> {
|
||||
update::cancel_download(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({ "canceled": true }))
|
||||
}
|
||||
|
||||
/// Apply the staged update.
|
||||
pub(super) async fn handle_update_apply(&self) -> Result<serde_json::Value> {
|
||||
update::apply_update(&self.config.data_dir).await?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use anyhow::{Context, Result};
|
||||
use chrono::Timelike;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
@@ -14,6 +14,27 @@ use tracing::{debug, info};
|
||||
/// download runs in one place at a time; no need for per-handler state.
|
||||
pub static DOWNLOAD_BYTES: AtomicU64 = AtomicU64::new(0);
|
||||
pub static DOWNLOAD_TOTAL: AtomicU64 = AtomicU64::new(0);
|
||||
/// Set true to ask the in-flight download loop to bail out at the next
|
||||
/// chunk boundary. Read via `is_canceled`; reset at the start of every
|
||||
/// `download_update` run. Also flipped by the `cancel_download` RPC.
|
||||
pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
/// Monotonic ms timestamp of the last time DOWNLOAD_BYTES advanced.
|
||||
/// Lets `update.status` flag a download as "stalled" when no bytes have
|
||||
/// arrived for a while, so the UI can offer a Cancel button with more
|
||||
/// confidence than "looks stuck at 0%".
|
||||
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_canceled() -> bool {
|
||||
DOWNLOAD_CANCEL.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
@@ -223,12 +244,20 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let mut downloaded = 0u64;
|
||||
let total_bytes: u64 = manifest.components.iter().map(|c| c.size_bytes).sum();
|
||||
|
||||
// Seed the live counters so polls during the handshake show the
|
||||
// right denominator immediately instead of 0/0 → NaN%.
|
||||
// Clear any stale cancel flag from a prior aborted run, then seed
|
||||
// the live counters so polls during the handshake show the right
|
||||
// denominator immediately instead of 0/0 → NaN%.
|
||||
DOWNLOAD_CANCEL.store(false, Ordering::Relaxed);
|
||||
DOWNLOAD_TOTAL.store(total_bytes, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed);
|
||||
|
||||
for component in &manifest.components {
|
||||
if is_canceled() {
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
info!(name = %component.name, url = %component.download_url, "Downloading component");
|
||||
let dest = staging_dir.join(&component.name);
|
||||
download_component_resumable(&client, component, &dest, downloaded).await?;
|
||||
@@ -289,7 +318,18 @@ async fn download_component_resumable(
|
||||
delay,
|
||||
last_err.as_ref().map(|e| e.to_string()).unwrap_or_default()
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
// Sleep in 500ms slices so a Cancel during backoff wakes
|
||||
// promptly instead of waiting out the full exponential window.
|
||||
let slices = delay * 2;
|
||||
for _ in 0..slices {
|
||||
if is_canceled() {
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
if is_canceled() {
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
|
||||
let mut req = client.get(&component.download_url);
|
||||
@@ -348,7 +388,12 @@ async fn download_component_resumable(
|
||||
let mut resp = resp;
|
||||
let mut stream_err = false;
|
||||
let mut on_disk = existing_len;
|
||||
let mut canceled = false;
|
||||
loop {
|
||||
if is_canceled() {
|
||||
canceled = true;
|
||||
break;
|
||||
}
|
||||
match resp.chunk().await {
|
||||
Ok(Some(bytes)) => {
|
||||
if let Err(e) = file.write_all(&bytes).await {
|
||||
@@ -361,6 +406,7 @@ async fn download_component_resumable(
|
||||
prior_total + on_disk.min(component.size_bytes),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed);
|
||||
}
|
||||
Ok(None) => break, // stream ended cleanly
|
||||
Err(e) => {
|
||||
@@ -370,6 +416,13 @@ async fn download_component_resumable(
|
||||
}
|
||||
}
|
||||
}
|
||||
if canceled {
|
||||
let _ = file.flush().await;
|
||||
drop(file);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
let _ = file.flush().await;
|
||||
let _ = file.sync_all().await;
|
||||
drop(file);
|
||||
@@ -414,6 +467,30 @@ async fn download_component_resumable(
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
}
|
||||
|
||||
/// Cancel an in-flight download. Sets the cancellation flag so the
|
||||
/// download loop bails out at the next chunk or backoff boundary, then
|
||||
/// zeros the live counters and wipes the staging directory so the UI
|
||||
/// sees "no active download" immediately and the next attempt starts
|
||||
/// clean. Safe to call even when no download is running.
|
||||
pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
let staging = data_dir.join("update-staging");
|
||||
if staging.exists() {
|
||||
let _ = tokio::fs::remove_dir_all(&staging).await;
|
||||
}
|
||||
// Clear the "downloaded, ready to apply" marker too — a canceled
|
||||
// download is not a staged update.
|
||||
if let Ok(mut state) = load_state(data_dir).await {
|
||||
if state.update_in_progress {
|
||||
state.update_in_progress = false;
|
||||
let _ = save_state(data_dir, &state).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command as root, but *outside* the archipelago service's
|
||||
/// restricted mount namespace.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user