collective

Browse / github.com/storytold/craft-launcher / patch

fix: discard stale app detection results

[Bug Report] [App Launcher] ArtCraft Launcher shows wrong update info ### Description I've been facing this issue since the new ArtCraft Launcher came out, I currently have 3 apps installed : Film Craft, Effect Craft, Photo Craft... I update all these apps through Launcher, although they update without any issues, the artcraft launcher still shows apps aren't updated, and does not refresh itself... ### Operating system and version Ubuntu 24.04.5 LTS <!-- ticket-relay:files --> ### Attachments ![Screenshot\_from\_2026-10-14\_21-32-44.png](https://tickets.devmesh.xyz/f/vdYtg-DSCe2QP4sCKgHxOg/Screenshot_from_2026-10-14_21-32-44.png) <!-- /ticket-relay:files -

Status
open
Score
0.00
Adopters
0 other operators
Author
BugBuster18 (imported from pull request #421, open)
Base
0f295d4b9c90d15c2e38e36c322bdbc0fa943a54
Size
+128 −16 in 1 file
Review
pending
Shared
2026-10-28
Id
a89600e4584b1dba6bb13fca

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/backend.rs

diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs--- a/crates/native/src/backend.rs+++ b/crates/native/src/backend.rs@@ -49,11 +49,67 @@ const WORKERS: usize = 4;  type Waker = Arc<dyn Fn() + Send + Sync>; +/// Invalidates detections whose package-manager snapshot predates an install/uninstall job.+/// The lock is also held while publishing a detection event, so an older event cannot be queued+/// after a newer job result.+fn bump_detection_generation(shared: &Shared, app_id: &str) {+    let mut generations = shared.detection_generations.lock().unwrap_or_else(PoisonError::into_inner);+    let generation = generations.entry(app_id.to_string()).or_default();+    *generation = generation.wrapping_add(1);+}++/// Publishes a background detection only when no install/uninstall job has superseded its+/// snapshot. `expected_generation` is captured before reading package-manager state.+fn publish_detection_if_current(shared: &Shared, app_id: &str, expected_generation: u64, installed: Option<Installed>) -> bool {+    let generations = shared.detection_generations.lock().unwrap_or_else(PoisonError::into_inner);+    if generations.get(app_id).copied().unwrap_or_default() != expected_generation {+        return false;+    }+    {+        let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);+        match &installed {+            Some(found) => {+                map.insert(app_id.to_string(), found.clone());+            }+            None => {+                map.remove(app_id);+            }+        }+    }+    shared.events.lock().unwrap_or_else(PoisonError::into_inner).push(Event::Detected { app: app_id.to_string(), installed });+    drop(generations);+    (shared.waker)();+    true+}++/// Publishes the fresh detection performed when a job ends, invalidating every older snapshot.+fn publish_job_detection(shared: &Shared, app_id: &str, installed: Option<Installed>) {+    let mut generations = shared.detection_generations.lock().unwrap_or_else(PoisonError::into_inner);+    let generation = generations.entry(app_id.to_string()).or_default();+    *generation = generation.wrapping_add(1);+    {+        let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);+        match &installed {+            Some(found) => {+                map.insert(app_id.to_string(), found.clone());+            }+            None => {+                map.remove(app_id);+            }+        }+    }+    shared.events.lock().unwrap_or_else(PoisonError::into_inner).push(Event::Detected { app: app_id.to_string(), installed });+    drop(generations);+    (shared.waker)();+}+ struct Shared {     events: Mutex<Vec<Event>>,     waker: Waker,     latest: Mutex<HashMap<String, Release>>,     installed: Mutex<HashMap<String, Installed>>,+    /// Per-app generations invalidate background detections made stale by install/uninstall jobs.+    detection_generations: Mutex<HashMap<String, u64>>,     jobs: Mutex<HashMap<String, Arc<Stop>>>,     /// The launcher is replacing itself and will restart: no new job starts.     restarting: AtomicBool,@@ -120,6 +176,7 @@ impl NativeBackend {             waker,             latest: Mutex::new(HashMap::new()),             installed: Mutex::new(HashMap::new()),+            detection_generations: Mutex::new(HashMap::new()),             jobs: Mutex::new(HashMap::new()),             restarting: AtomicBool::new(false),             manifest: Mutex::new(Manifest::load(&paths.manifest())),@@ -182,18 +239,15 @@ impl NativeBackend {             // Leftovers from interrupted installs or older versions of the launcher. A paused             // download stays a month; a finished one kept after a failed install, a week.             http::clean_downloads(&paths.downloads(), Duration::from_secs(30 * 86_400), Duration::from_secs(7 * 86_400));+            // Capture before the package-manager snapshot: a job that changes state while the+            // snapshot or this loop runs must prevent that older result from being published.+            let generations = shared.detection_generations.lock().unwrap_or_else(PoisonError::into_inner).clone();             let snapshot = crate::detect::Snapshot::take();             let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();             for app in &SUITE {                 let found = crate::detect::detect(app, &manifest, &snapshot);-                {-                    let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);-                    match &found {-                        Some(i) => map.insert(app.id.to_string(), i.clone()),-                        None => map.remove(app.id),-                    };-                }-                shared.emit(Event::Detected { app: app.id.to_string(), installed: found });+                let generation = generations.get(app.id).copied().unwrap_or_default();+                publish_detection_if_current(&shared, app.id, generation, found);             }             // Downloads paused (or cut off by quitting) in an earlier run.             for app in &SUITE {@@ -330,6 +384,8 @@ impl NativeBackend {             self.shared.emit(Event::JobFinished { app: app.id.to_string(), result });             return;         }+        // Invalidate any detection whose system snapshot overlaps this install/uninstall.+        bump_detection_generation(&self.shared, app_id);         let shared = self.shared.clone();         let paths = self.paths.clone();         let http = self.http.clone();@@ -376,14 +432,7 @@ impl NativeBackend {                 }                 (result, _) => result,             };-            {-                let mut map = shared.installed.lock().unwrap_or_else(PoisonError::into_inner);-                match &found {-                    Some(i) => map.insert(app.id.to_string(), i.clone()),-                    None => map.remove(app.id),-                };-            }-            shared.emit(Event::Detected { app: app.id.to_string(), installed: found });+            publish_job_detection(&shared, app.id, found);             shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).remove(app.id);             shared.emit(Event::JobFinished { app: app.id.to_string(), result });         });@@ -1026,6 +1075,7 @@ mod tests {             waker: Arc::new(|| {}),             latest: Mutex::new(HashMap::new()),             installed: Mutex::new(HashMap::new()),+            detection_generations: Mutex::new(HashMap::new()),             jobs: Mutex::new(HashMap::new()),             restarting: AtomicBool::new(false),             manifest: Mutex::new(Manifest::default()),@@ -1044,6 +1094,68 @@ mod tests {         }     } +    fn flatpak_installed(version_text: &str) -> Installed {+        Installed {+            version: artcraft_launcher_core::version::parse(version_text),+            source: InstallSource::Flatpak,+            location: "ai.storyteller.photocraft".into(),+            package: Some("ai.storyteller.photocraft".into()),+        }+    }++    #[test]+    fn stale_detection_is_discarded_after_an_app_job_changes_its_generation() {+        let state = shared();+        let old = flatpak_installed("0.6.0");+        let fresh = flatpak_installed("0.7.0");+        state.installed.lock().unwrap_or_else(PoisonError::into_inner).insert("photocraft".into(), old);+        let snapshot_generation =+            state.detection_generations.lock().unwrap_or_else(PoisonError::into_inner).get("photocraft").copied().unwrap_or_default();++        // The scan snapshots the old generation before the job starts. A later job result must+        // win even when the older scan tries to publish after that result.+        bump_detection_generation(&state, "photocraft");+        publish_job_detection(&state, "photocraft", Some(fresh.clone()));+        assert!(!publish_detection_if_current(&state, "photocraft", snapshot_generation, None));++        assert_eq!(state.installed.lock().unwrap_or_else(PoisonError::into_inner).get("photocraft"), Some(&fresh));+        let events = state.events.lock().unwrap_or_else(PoisonError::into_inner);+        assert_eq!(events.len(), 1);+        match &events[0] {+            Event::Detected { app, installed } => {+                assert_eq!(app, "photocraft");+                assert_eq!(installed.as_ref(), Some(&fresh));+            }+            other => panic!("expected a detection event, got {other:?}"),+        }+    }++    #[test]+    fn detection_started_during_an_app_job_is_discarded_after_the_job_finishes() {+        let state = shared();+        let stale = flatpak_installed("0.6.0");+        let fresh = flatpak_installed("0.7.0");++        // The job has started, so a concurrent scan captures the post-start generation. The+        // job's final publication must invalidate that scan too.+        bump_detection_generation(&state, "photocraft");+        let snapshot_generation =+            state.detection_generations.lock().unwrap_or_else(PoisonError::into_inner).get("photocraft").copied().unwrap_or_default();+        publish_job_detection(&state, "photocraft", Some(fresh.clone()));+        assert!(!publish_detection_if_current(&state, "photocraft", snapshot_generation, Some(stale)));++        assert_eq!(state.installed.lock().unwrap_or_else(PoisonError::into_inner).get("photocraft"), Some(&fresh));+        let events = state.events.lock().unwrap_or_else(PoisonError::into_inner);+        assert_eq!(events.len(), 1);+        match &events[0] {+            Event::Detected { app, installed } => {+                assert_eq!(app, "photocraft");+                assert_eq!(installed.as_ref(), Some(&fresh));+            }+            other => panic!("expected a detection event, got {other:?}"),+        }+    }+     /// A local server answering every request with `status` (and `Retry-After: 120` on a 429),     /// counting the requests.     fn serve(status: &'static str) -> (String, Arc<AtomicUsize>) {