From fc8db259a9cd53bfe9cdfbb61839677ea92cb033 Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:16:43 +0530 Subject: [PATCH] fix(bootstrap): prevent stale timeout after retry invalidation --- CHANGELOG.md | 2 +- docs/install/troubleshooting.md | 2 + frontend/src-tauri/src/bootstrap.rs | 62 ++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efd0f9aa..a974e1e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed -- Slow backend startups remain running with progress updates, and Retry can interrupt startup reliably (#1809) +- Slow backend startups remain running with progress updates, and Retry interrupts startup without stale timeout failures (#1809) ## [0.5.2] — 2026-09-02 diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index 87c92a8c..9b8e10c7 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -654,6 +654,8 @@ the error persistently on a current build, that's section **14** (a wedged GPU job), section **14d** (the backend never started), or the crash notice above — not this window. +A startup timeout from an earlier attempt cannot replace the current startup state after **Retry** takes over. + Desktop startup, **Retry**, storage reset, setup re-entry, in-app uninstall, app shutdown, and automatic crash recovery also share one backend lifecycle owner. Overlapping start attempts wait and attach to the healthy process, diff --git a/frontend/src-tauri/src/bootstrap.rs b/frontend/src-tauri/src/bootstrap.rs index 2fc9411a..e3cb8d27 100644 --- a/frontend/src-tauri/src/bootstrap.rs +++ b/frontend/src-tauri/src/bootstrap.rs @@ -894,7 +894,14 @@ fn spawn_backend_until_ready( err_tail ) }; - set_stage(stage_handle, BootstrapStage::Failed { message: msg }); + publish_backend_wait_timeout( + stage_handle, + &LAST_FAILURE, + &BACKEND_WAIT_GENERATION, + &BACKEND_WAIT_PUBLICATION, + wait_generation, + msg, + ); return false; } } @@ -935,10 +942,32 @@ static BACKEND_KILL_INTENDED: AtomicBool = AtomicBool::new(false); /// flow bumps this BEFORE reaching for the lock; the waiting loop sees the /// change within one poll, returns, and releases it (Greptile, #1809). static BACKEND_WAIT_GENERATION: AtomicU64 = AtomicU64::new(0); +// Serialize invalidation with the final timeout publication. The lifecycle +// lock cannot do this: Retry deliberately invalidates before acquiring it. +static BACKEND_WAIT_PUBLICATION: Mutex<()> = Mutex::new(()); + +fn publish_backend_wait_timeout( + state: &Arc>, + last_failure: &Mutex>, + generation: &AtomicU64, + publication: &Mutex<()>, + expected_generation: u64, + message: String, +) -> bool { + let _publication = publication.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if generation.load(Ordering::SeqCst) != expected_generation { + return false; + } + set_stage_into(state, last_failure, BootstrapStage::Failed { message }); + true +} /// Ask any in-flight readiness wait to stand down, so this caller can take /// lifecycle ownership. Call before locking, never while holding the lock. pub fn preempt_backend_wait() { + let _publication = BACKEND_WAIT_PUBLICATION + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); BACKEND_WAIT_GENERATION.fetch_add(1, Ordering::SeqCst); } @@ -3541,6 +3570,37 @@ mod tests { assert_eq!(backend_wait_generation(), theirs); } + #[test] + fn retry_after_wait_expiry_does_not_publish_a_stale_timeout() { + let generation = AtomicU64::new(7); + let publication = Mutex::new(()); + let state = Arc::new(Mutex::new(BootstrapStage::Checking)); + let failure = Mutex::new(Some("earlier diagnosis".to_string())); + let snapshot = generation.load(Ordering::SeqCst); + assert!(!keep_waiting_for_backend(None, Duration::from_secs(301), Duration::from_secs(300))); + // Retry invalidates after the loop condition fails, before the old + // waiter finishes collecting stderr and publishes its timeout. + generation.fetch_add(1, Ordering::SeqCst); + assert!(!publish_backend_wait_timeout( + &state, &failure, &generation, &publication, snapshot, "stale timeout".into() + )); + assert!(matches!(*state.lock().unwrap(), BootstrapStage::Checking)); + assert_eq!(failure.lock().unwrap().as_deref(), Some("earlier diagnosis")); + } + + #[test] + fn current_wait_expiry_still_publishes_and_retains_its_timeout() { + let generation = AtomicU64::new(7); + let publication = Mutex::new(()); + let state = Arc::new(Mutex::new(BootstrapStage::StartingBackend)); + let failure = Mutex::new(None); + assert!(publish_backend_wait_timeout( + &state, &failure, &generation, &publication, 7, "current timeout".into() + )); + assert!(matches!(*state.lock().unwrap(), BootstrapStage::Failed { .. })); + assert_eq!(failure.lock().unwrap().as_deref(), Some("current timeout")); + } + #[test] fn restart_backoff_escalates_but_first_respawn_is_immediate() { // A one-off crash self-heals with zero added latency; repeat deaths