From f853d1442129fb816aa483f7092a74c8f3ae79fe Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Apr 2026 19:10:34 -0400 Subject: [PATCH] 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) --- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/api/rpc/update.rs | 29 +++++++ core/archipelago/src/update.rs | 85 ++++++++++++++++++- neode-ui/src/locales/en.json | 10 ++- neode-ui/src/locales/es.json | 22 +++-- neode-ui/src/views/SystemUpdate.vue | 73 +++++++++++++--- .../src/views/settings/AccountInfoSection.vue | 12 +++ 9 files changed, 209 insertions(+), 27 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 4e45e6c2..08195110 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.16-alpha" +version = "1.7.17-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index be7959ab..524abbf8 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -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"] diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 88187771..6f663d3d 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -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, diff --git a/core/archipelago/src/api/rpc/update.rs b/core/archipelago/src/api/rpc/update.rs index f2e70751..9091090c 100644 --- a/core/archipelago/src/api/rpc/update.rs +++ b/core/archipelago/src/api/rpc/update.rs @@ -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 { + 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 { update::apply_update(&self.config.data_dir).await?; diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 41ee5c11..fc77c8b8 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -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 { 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. /// diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json index 2c3d8d74..e12b932c 100644 --- a/neode-ui/src/locales/en.json +++ b/neode-ui/src/locales/en.json @@ -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", diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json index d6ea299f..c0b45b7d 100644 --- a/neode-ui/src/locales/es.json +++ b/neode-ui/src/locales/es.json @@ -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", diff --git a/neode-ui/src/views/SystemUpdate.vue b/neode-ui/src/views/SystemUpdate.vue index 585cfdbb..a32cd83d 100644 --- a/neode-ui/src/views/SystemUpdate.vue +++ b/neode-ui/src/views/SystemUpdate.vue @@ -109,17 +109,30 @@

{{ t('systemUpdate.downloading') }}

-
-
-

- {{ downloadFinishing - ? t('systemUpdate.finishingDownload') - : t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }} -

+
+
+
+

+ {{ downloadStalled + ? t('systemUpdate.downloadStalled') + : downloadFinishing + ? t('systemUpdate.finishingDownload') + : t('systemUpdate.percentComplete', { percent: downloadPercentFormatted }) }} +

+
+
@@ -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') }}

{{ confirmAction === 'rollback' ? t('systemUpdate.rollbackMessage') : confirmAction === 'git-apply' ? t('systemUpdate.gitApplyMessage') - : t('systemUpdate.applyMessage') }} + : confirmAction === 'cancel-download' + ? t('systemUpdate.cancelDownloadConfirm') + : t('systemUpdate.applyMessage') }}

@@ -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(null) const updateInfo = ref(null) @@ -335,13 +356,16 @@ async function pollDownloadProgress(): Promise { 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 } } diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 63ffda5e..f1cdc62f 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -180,6 +180,18 @@ init()
+ +
+
+ v1.7.17-alpha + Apr 20, 2026 +
+
+

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.

+

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.

+

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.

+
+