fix(updater): retry the check and download, report progress
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-windows (push) Canceled after 4m13s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s

A friend's in-app update failed ten times ("error sending request for
url (…nsis.zip)") against a busy Gitea before the 11th worked.

- check_for_update / install_update retry transport errors (Reqwest,
  Network, ReleaseNotFound) with 3 s / 8 s / 15 s backoff — four tries
  per click. Signature or install errors fail at once, never masked.
- Every request gets a 600 s timeout; reqwest has none by default, so a
  stalled connection could leave the UI on "installing" forever.
- Download and install are separate steps so a retry never re-installs.
- `lotus-update-progress` events (downloading %, retrying + wait,
  installing) drive the new progress text in Settings.
- Errors are prefixed `check:` / `download:` / `install:` so the UI says
  which step failed (it used to call a failed download "Update check
  failed").
- Adds tokio (time feature only; already in the tree via tauri).
- Bump cinny to 568f218f (the matching UI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-23 20:54:19 -04:00
co-authored by Claude Opus 5.5
parent 5a9bfa8fe4
commit 8f98a82562
4 changed files with 130 additions and 25 deletions
+1 -1
Submodule cinny updated: 23649f1255...568f218fe9
+1
View File
@@ -510,6 +510,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
"webkit2gtk",
"webview2-com",
"window-vibrancy",
+2
View File
@@ -36,6 +36,8 @@ custom-protocol = [ "tauri/custom-protocol" ]
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2" # P6-1 launch-on-login
# Update retry backoff (already in the tree via tauri; adds only the timer).
tokio = { version = "1", features = ["time"] }
[target.'cfg(target_os = "linux")'.dependencies]
# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher
+126 -24
View File
@@ -130,46 +130,148 @@ struct UpdateInfo {
version: Option<String>,
}
/// Update-flow resilience (Cole, 2026-09-23: ten failed "error sending request"
/// attempts against a busy Gitea before the 11th went through). Network-level
/// failures are retried with backoff inside one click; anything else (bad
/// signature, broken package) fails at once so it is never masked.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod update_retry {
use std::time::Duration;
/// Delays before attempts 2, 3, 4 (so four tries in all).
pub const BACKOFF: [Duration; 3] = [
Duration::from_secs(3),
Duration::from_secs(8),
Duration::from_secs(15),
];
/// Per-request cap. reqwest has no default timeout, so a stalled connection
/// could otherwise hang the "installing" state forever. Generous enough for
/// the ~50 MB installer on a slow line.
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
/// Only transport errors are worth retrying.
pub fn is_transient(err: &tauri_plugin_updater::Error) -> bool {
use tauri_plugin_updater::Error;
matches!(
err,
Error::Reqwest(_) | Error::Network(_) | Error::ReleaseNotFound
)
}
pub fn updater(
app: &tauri::AppHandle,
) -> Result<tauri_plugin_updater::Updater, String> {
use tauri_plugin_updater::UpdaterExt;
app.updater_builder()
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|e| e.to_string())
}
/// `check()` with retries. `Ok(None)` = up to date.
pub async fn check(
app: &tauri::AppHandle,
) -> Result<Option<tauri_plugin_updater::Update>, String> {
let updater = updater(app)?;
let mut attempt = 0;
loop {
match updater.check().await {
Ok(found) => return Ok(found),
Err(e) if is_transient(&e) && attempt < BACKOFF.len() => {
tokio::time::sleep(BACKOFF[attempt]).await;
attempt += 1;
}
Err(e) => return Err(e.to_string()),
}
}
}
}
#[tauri::command]
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_updater::UpdaterExt;
return match app.updater().map_err(|e| e.to_string())?.check().await {
Ok(Some(update)) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
Ok(None) => Ok(UpdateInfo { available: false, version: None }),
Err(e) => Err(e.to_string()),
return match update_retry::check(&app).await? {
Some(update) => Ok(UpdateInfo { available: true, version: Some(update.version) }),
None => Ok(UpdateInfo { available: false, version: None }),
};
}
#[cfg(any(target_os = "android", target_os = "ios"))]
Ok(UpdateInfo { available: false, version: None })
}
/// Download (with retries and `lotus-update-progress` events for the web UI),
/// then install and relaunch. Errors are prefixed `check:` / `download:` /
/// `install:` so the web side can say which step failed.
#[tauri::command]
async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_updater::UpdaterExt;
if let Some(update) = app
.updater()
.map_err(|e| e.to_string())?
.check()
use std::time::{Duration, Instant};
let emit = |detail: serde_json::Value| {
native::emit_to_web(&app, "lotus-update-progress", &detail.to_string());
};
let Some(update) = update_retry::check(&app)
.await
.map_err(|e| e.to_string())?
{
update
.download_and_install(|_chunk, _total| {}, || {})
.await
.map_err(|e| e.to_string())?;
// Only reached on a successful download+install (the `?` above bails
// otherwise). Relaunch so the freshly installed version actually
// runs — without this the UI hangs on "installing", and on a Linux
// AppImage the running process is still the old binary. `restart()`
// exits the current process and never returns, so nothing after it
// runs for the update case.
app.restart();
}
.map_err(|e| format!("check: {e}"))?
else {
return Ok(());
};
let max_attempts = update_retry::BACKOFF.len() + 1;
let mut attempt = 0;
let bytes = loop {
emit(serde_json::json!({
"phase": "downloading", "attempt": attempt + 1, "maxAttempts": max_attempts,
"downloaded": 0, "total": null,
}));
let mut downloaded: u64 = 0;
let mut last_emit = Instant::now();
let result = update
.download(
|chunk, total| {
downloaded += chunk as u64;
// ~4 Hz is plenty for a progress line.
if last_emit.elapsed() >= Duration::from_millis(250) {
last_emit = Instant::now();
emit(serde_json::json!({
"phase": "downloading", "attempt": attempt + 1,
"maxAttempts": max_attempts,
"downloaded": downloaded, "total": total,
}));
}
},
|| {},
)
.await;
match result {
Ok(bytes) => break bytes,
Err(e) if update_retry::is_transient(&e) && attempt < update_retry::BACKOFF.len() => {
let wait = update_retry::BACKOFF[attempt];
emit(serde_json::json!({
"phase": "retrying", "attempt": attempt + 1, "maxAttempts": max_attempts,
"waitSecs": wait.as_secs(), "error": e.to_string(),
}));
tokio::time::sleep(wait).await;
attempt += 1;
}
Err(e) => return Err(format!("download: {e}")),
}
};
emit(serde_json::json!({ "phase": "installing" }));
update.install(bytes).map_err(|e| format!("install: {e}"))?;
// Only reached on a successful install. Relaunch so the freshly
// installed version actually runs — without this the UI hangs on
// "installing", and on a Linux AppImage the running process is still the
// old binary. On Windows the plugin has already exited the process to
// run the installer, so this is not reached there.
app.restart();
}
#[allow(unreachable_code)]
Ok(())
}