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:
Dorian 2026-04-20 19:10:34 -04:00
parent f2360d570f
commit f853d14421
9 changed files with 209 additions and 27 deletions

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.16-alpha"
version = "1.7.17-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.16-alpha"
version = "1.7.17-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@ -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,

View File

@ -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?;

View File

@ -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.
///

View File

@ -696,7 +696,15 @@
"gitMethodHint": "This node builds from source. Update will git-pull, rebuild the backend and UI, then restart — takes a few minutes.",
"gitApplyTitle": "Pull & Rebuild?",
"gitApplyMessage": "Archipelago will pull the latest code, rebuild, and restart. This can take several minutes and the UI will be briefly unavailable.",
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes."
"gitApplyStarted": "Update started. The backend will rebuild and restart — this can take a few minutes.",
"cancelDownload": "Cancel Download",
"cancelingDownload": "Canceling…",
"cancelDownloadTitle": "Cancel Download?",
"cancelDownloadConfirm": "This will stop the current download and discard the partial file. You can start again from scratch afterwards.",
"cancelDownloadButton": "Cancel Download",
"cancelDownloadSuccess": "Download canceled. You can try again.",
"cancelDownloadFailed": "Failed to cancel download.",
"downloadStalled": "Download appears stuck — try Cancel and start again."
},
"kiosk": {
"pressEsc": "Press ESC to exit",

View File

@ -684,18 +684,26 @@
"rollbackSuccess": "Se revirti\u00f3 a la versi\u00f3n anterior. El servicio se reiniciar\u00e1.",
"rollbackFailed": "Error al revertir.",
"pullAndRebuild": "Pull y Recompilar",
"finishingDownload": "Terminando descarga — verificando checksum…",
"overlayApplying": "Instalando actualizaci\u00f3n",
"overlayRestarting": "Reiniciando servidor",
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n",
"overlayReady": "Actualizaci\u00f3n instalada — recargando…",
"finishingDownload": "Terminando descarga \u2014 verificando checksum\u2026",
"overlayApplying": "Instalando actualizaci\u00f3n\u2026",
"overlayRestarting": "Reiniciando servidor\u2026",
"overlayReconnecting": "Reconectando a la nueva versi\u00f3n\u2026",
"overlayReady": "Actualizaci\u00f3n instalada \u2014 recargando\u2026",
"overlayStalled": "Tardando m\u00e1s de lo esperado",
"overlayTarget": "Instalando v{version}",
"overlayReloadNow": "Recargar ahora",
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 tarda unos minutos.",
"gitMethodHint": "Este nodo compila desde el c\u00f3digo fuente. La actualizaci\u00f3n har\u00e1 git-pull, recompilar\u00e1 y reiniciar\u00e1 \u2014 tarda unos minutos.",
"gitApplyTitle": "\u00bfPull y Recompilar?",
"gitApplyMessage": "Archipelago descargar\u00e1 el c\u00f3digo m\u00e1s reciente, lo compilar\u00e1 y reiniciar\u00e1. Puede tardar varios minutos y la UI estar\u00e1 brevemente no disponible.",
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 — puede tardar unos minutos."
"gitApplyStarted": "Actualizaci\u00f3n iniciada. El backend se recompilar\u00e1 y reiniciar\u00e1 \u2014 puede tardar unos minutos.",
"cancelDownload": "Cancelar descarga",
"cancelingDownload": "Cancelando\u2026",
"cancelDownloadTitle": "\u00bfCancelar descarga?",
"cancelDownloadConfirm": "Esto detendr\u00e1 la descarga actual y descartar\u00e1 el archivo parcial. Podr\u00e1s volver a empezar desde cero.",
"cancelDownloadButton": "Cancelar descarga",
"cancelDownloadSuccess": "Descarga cancelada. Puedes intentarlo de nuevo.",
"cancelDownloadFailed": "No se pudo cancelar la descarga.",
"downloadStalled": "La descarga parece atascada \u2014 prueba a cancelar y volver a empezar."
},
"kiosk": {
"pressEsc": "Presione ESC para salir",

View File

@ -109,17 +109,30 @@
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.downloading') }}</h2>
<div class="w-full h-3 bg-white/10 rounded-full overflow-hidden mb-2">
<div
class="h-full bg-orange-400 rounded-full transition-all duration-500"
class="h-full rounded-full transition-all duration-500"
:class="downloadStalled ? 'bg-amber-400' : 'bg-orange-400'"
:style="{ width: downloadPercentFormatted + '%' }"
></div>
</div>
<div class="flex items-center gap-2">
<div v-if="downloadFinishing" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
<p class="text-xs text-white/60">
{{ downloadFinishing
? t('systemUpdate.finishingDownload')
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
</p>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0">
<div v-if="downloadFinishing && !downloadStalled" class="w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin shrink-0"></div>
<p class="text-xs" :class="downloadStalled ? 'text-amber-300' : 'text-white/60'">
{{ downloadStalled
? t('systemUpdate.downloadStalled')
: downloadFinishing
? t('systemUpdate.finishingDownload')
: t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }}
</p>
</div>
<button
@click="requestCancelDownload"
:disabled="cancelingDownload"
class="glass-button rounded-lg px-4 py-1.5 text-xs font-medium disabled:opacity-40 shrink-0"
:class="downloadStalled ? 'bg-amber-500/20 border-amber-400/40 text-amber-200' : ''"
>
{{ cancelingDownload ? t('systemUpdate.cancelingDownload') : t('systemUpdate.cancelDownload') }}
</button>
</div>
</div>
@ -253,14 +266,18 @@
? t('systemUpdate.rollbackTitle')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyTitle')
: t('systemUpdate.applyTitle') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadTitle')
: t('systemUpdate.applyTitle') }}
</h3>
<p class="text-sm text-white/70 mb-6">
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackMessage')
: confirmAction === 'git-apply'
? t('systemUpdate.gitApplyMessage')
: t('systemUpdate.applyMessage') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadConfirm')
: t('systemUpdate.applyMessage') }}
</p>
<div class="flex gap-3 justify-end">
<button @click="cancelConfirm" class="glass-button rounded-lg px-4 py-2 text-sm font-medium">
@ -269,13 +286,15 @@
<button
@click="executeConfirm"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="confirmAction === 'rollback' ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
:class="(confirmAction === 'rollback' || confirmAction === 'cancel-download') ? 'bg-red-500/20 border-red-400/30' : 'bg-orange-500/20 border-orange-400/30'"
>
{{ confirmAction === 'rollback'
? t('systemUpdate.rollbackButton')
: confirmAction === 'git-apply'
? t('systemUpdate.pullAndRebuild')
: t('systemUpdate.applyNow') }}
: confirmAction === 'cancel-download'
? t('systemUpdate.cancelDownloadButton')
: t('systemUpdate.applyNow') }}
</button>
</div>
</div>
@ -313,7 +332,9 @@ const loading = ref(false)
const downloading = ref(false)
const downloaded = ref(false)
const applying = ref(false)
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | null>(null)
const cancelingDownload = ref(false)
const downloadStalled = ref(false)
const confirmAction = ref<'apply' | 'git-apply' | 'rollback' | 'cancel-download' | null>(null)
const currentVersion = ref('0.0.0')
const lastCheck = ref<string | null>(null)
const updateInfo = ref<UpdateDetail | null>(null)
@ -335,13 +356,16 @@ async function pollDownloadProgress(): Promise<boolean> {
bytes_downloaded: number
total_bytes: number
active: boolean
stalled?: boolean
} | null
}>({ method: 'update.status' })
const p = res.download_progress
if (p && p.total_bytes > 0) {
downloadPercent.value = Math.min(100, (p.bytes_downloaded / p.total_bytes) * 100)
downloadStalled.value = !!p.stalled
return p.active
}
downloadStalled.value = false
return false
} catch {
return false
@ -547,6 +571,10 @@ function requestRollback() {
confirmAction.value = 'rollback'
}
function requestCancelDownload() {
confirmAction.value = 'cancel-download'
}
function cancelConfirm() {
confirmAction.value = null
}
@ -560,6 +588,25 @@ async function executeConfirm() {
await applyUpdateGitWithOverlay()
} else if (action === 'rollback') {
await rollbackUpdate()
} else if (action === 'cancel-download') {
await cancelDownload()
}
}
async function cancelDownload() {
cancelingDownload.value = true
try {
await rpcClient.call({ method: 'update.cancel-download' })
downloading.value = false
downloaded.value = false
downloadPercent.value = 0
downloadStalled.value = false
showStatus(t('systemUpdate.cancelDownloadSuccess'))
} catch (e) {
showStatus(t('systemUpdate.cancelDownloadFailed'), true)
if (import.meta.env.DEV) console.warn('Cancel download failed', e)
} finally {
cancelingDownload.value = false
}
}

View File

@ -180,6 +180,18 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.17-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.17-alpha</span>
<span class="text-xs text-white/40">Apr 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>When a download gets stuck, you can now cancel it. A new Cancel Download button sits next to the progress bar it stops the transfer, clears the partial file, and returns you to a clean state so you can retry. No more staring at a frozen bar with no way to recover.</p>
<p>Downloads that stall for 30 seconds or more now say so. The progress bar turns amber and shows 'Download appears stuck — try Cancel and start again' instead of just sitting silently at whatever percent it reached.</p>
<p>Canceling is fast. It no longer has to wait out the retry timer the download bails within half a second, so you're not stuck watching a stuck screen while you wait to unstick it.</p>
</div>
</div>
<!-- v1.7.16-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">