From 82cfc8ccba2b8ac75b0b63b28a2127e24734dafe Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 10:31:12 -0400 Subject: [PATCH] fix(update): failed download returns to Download, not Install (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resumable-but-failed download leaves partial component files in update-staging. has_staged_update() treated ANY staged file as "install-ready", so the state self-heal kept update_in_progress=true and the UI showed Install instead of Download (no clean retry). - update.rs: write a .download-complete marker only after EVERY component downloads+verifies; has_staged_update() now checks that marker. Partial/failed downloads (no marker) correctly read as not-staged → self-heal clears update_in_progress → UI shows Download. Resume still works (partial files kept). - SystemUpdate.vue: on a genuine download failure, reset downloaded/in_progress and re-sync, so the user lands back on Download immediately. cargo check + vue-tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/update.rs | 26 ++++++++++++++++++++------ neode-ui/src/views/SystemUpdate.vue | 7 +++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 03bfae1f..c1601123 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -538,12 +538,23 @@ pub async fn load_state(data_dir: &Path) -> Result { Ok(state) } +/// Marker written only after EVERY component has downloaded and verified. +/// Distinguishes a complete, install-ready staging from the partial files a +/// resumable-but-failed download leaves behind. +const STAGED_COMPLETE_MARKER: &str = ".download-complete"; + async fn has_staged_update(data_dir: &Path) -> bool { - let staging_dir = data_dir.join("update-staging"); - let Ok(mut entries) = fs::read_dir(&staging_dir).await else { - return false; - }; - matches!(entries.next_entry().await, Ok(Some(_))) + // A *complete* staged update carries the marker. A partial/failed download + // leaves component files (kept for resume) but no marker, so it reads as + // "not staged" — the state self-heal then clears update_in_progress and the + // UI returns to Download instead of stranding the user on Install. + fs::metadata( + data_dir + .join("update-staging") + .join(STAGED_COMPLETE_MARKER), + ) + .await + .is_ok() } pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> { @@ -801,7 +812,10 @@ pub async fn download_update(data_dir: &Path) -> Result { ); } - // Mark update as downloaded + // Mark update as downloaded. Write the completion marker FIRST so a crash + // between the two can't leave update_in_progress=true without the marker + // (which the self-heal would then clear, harmlessly forcing a re-download). + let _ = fs::write(staging_dir.join(STAGED_COMPLETE_MARKER), b"1").await; let mut state = load_state(data_dir).await?; state.update_in_progress = true; save_state(data_dir, &state).await?; diff --git a/neode-ui/src/views/SystemUpdate.vue b/neode-ui/src/views/SystemUpdate.vue index 3f8c8993..d36ae969 100644 --- a/neode-ui/src/views/SystemUpdate.vue +++ b/neode-ui/src/views/SystemUpdate.vue @@ -871,6 +871,13 @@ async function downloadUpdate() { await loadStatus() showStatus(t('systemUpdate.upToDateMessage')) } else { + // A failed download is NOT a staged update — return the UI to the + // Download button so the user can retry, instead of stranding them on + // Install. Re-sync from the backend (its self-heal clears a stale + // update_in_progress once the partial staging is cleaned up). + downloaded.value = false + updateInProgress.value = false + await loadStatus() showStatus(`${t('systemUpdate.downloadFailed')} ${msg}`, true) } if (import.meta.env.DEV) console.warn('Download failed', e)