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
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:
co-authored by
Claude Opus 5.5
parent
5a9bfa8fe4
commit
8f98a82562
+1
-1
Submodule cinny updated: 23649f1255...568f218fe9
Generated
+1
@@ -510,6 +510,7 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user