image viewer: Ensure images can never be loaded twice (#20472)

Bennet Bo Fenner created

Follow up to #20374, this prevents a race condition where we could load
images twice.

Release Notes:

- N/A

Change summary

crates/project/src/image_store.rs | 137 +++++++++++++++++++++++++++++++-
1 file changed, 130 insertions(+), 7 deletions(-)

Detailed changes

crates/project/src/image_store.rs 🔗

@@ -1,14 +1,16 @@
-use crate::worktree_store::{WorktreeStore, WorktreeStoreEvent};
-use crate::{Project, ProjectEntryId, ProjectPath};
+use crate::{
+    worktree_store::{WorktreeStore, WorktreeStoreEvent},
+    Project, ProjectEntryId, ProjectPath,
+};
 use anyhow::{Context as _, Result};
-use collections::{HashMap, HashSet};
-use futures::channel::oneshot;
+use collections::{hash_map, HashMap, HashSet};
+use futures::{channel::oneshot, StreamExt};
 use gpui::{
     hash, prelude::*, AppContext, EventEmitter, Img, Model, ModelContext, Subscription, Task,
     WeakModel,
 };
 use language::File;
-use rpc::AnyProtoClient;
+use rpc::{AnyProtoClient, ErrorExt as _};
 use std::ffi::OsStr;
 use std::num::NonZeroU64;
 use std::path::Path;
@@ -180,6 +182,11 @@ pub struct ImageStore {
     state: Box<dyn ImageStoreImpl>,
     opened_images: HashMap<ImageId, WeakModel<ImageItem>>,
     worktree_store: Model<WorktreeStore>,
+    #[allow(clippy::type_complexity)]
+    loading_images_by_path: HashMap<
+        ProjectPath,
+        postage::watch::Receiver<Option<Result<Model<ImageItem>, Arc<anyhow::Error>>>>,
+    >,
 }
 
 impl ImageStore {
@@ -204,6 +211,7 @@ impl ImageStore {
                 }
             })),
             opened_images: Default::default(),
+            loading_images_by_path: Default::default(),
             worktree_store,
         }
     }
@@ -217,6 +225,7 @@ impl ImageStore {
         Self {
             state: Box::new(cx.new_model(|_| RemoteImageStore {})),
             opened_images: Default::default(),
+            loading_images_by_path: Default::default(),
             worktree_store,
         }
     }
@@ -256,8 +265,57 @@ impl ImageStore {
             return Task::ready(Err(anyhow::anyhow!("no such worktree")));
         };
 
-        self.state
-            .open_image(project_path.path.clone(), worktree, cx)
+        let loading_watch = match self.loading_images_by_path.entry(project_path.clone()) {
+            // If the given path is already being loaded, then wait for that existing
+            // task to complete and return the same image.
+            hash_map::Entry::Occupied(e) => e.get().clone(),
+
+            // Otherwise, record the fact that this path is now being loaded.
+            hash_map::Entry::Vacant(entry) => {
+                let (mut tx, rx) = postage::watch::channel();
+                entry.insert(rx.clone());
+
+                let project_path = project_path.clone();
+                let load_image = self
+                    .state
+                    .open_image(project_path.path.clone(), worktree, cx);
+
+                cx.spawn(move |this, mut cx| async move {
+                    let load_result = load_image.await;
+                    *tx.borrow_mut() = Some(this.update(&mut cx, |this, _cx| {
+                        // Record the fact that the image is no longer loading.
+                        this.loading_images_by_path.remove(&project_path);
+                        let image = load_result.map_err(Arc::new)?;
+                        Ok(image)
+                    })?);
+                    anyhow::Ok(())
+                })
+                .detach();
+                rx
+            }
+        };
+
+        cx.background_executor().spawn(async move {
+            Self::wait_for_loading_image(loading_watch)
+                .await
+                .map_err(|e| e.cloned())
+        })
+    }
+
+    pub async fn wait_for_loading_image(
+        mut receiver: postage::watch::Receiver<
+            Option<Result<Model<ImageItem>, Arc<anyhow::Error>>>,
+        >,
+    ) -> Result<Model<ImageItem>, Arc<anyhow::Error>> {
+        loop {
+            if let Some(result) = receiver.borrow().as_ref() {
+                match result {
+                    Ok(image) => return Ok(image.to_owned()),
+                    Err(e) => return Err(e.to_owned()),
+                }
+            }
+            receiver.next().await;
+        }
     }
 
     pub fn reload_images(
@@ -582,3 +640,68 @@ impl ImageStoreImpl for Model<RemoteImageStore> {
         None
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use std::path::PathBuf;
+
+    pub fn init_test(cx: &mut TestAppContext) {
+        if std::env::var("RUST_LOG").is_ok() {
+            env_logger::try_init().ok();
+        }
+
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            language::init(cx);
+            Project::init_settings(cx);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_image_not_loaded_twice(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+
+        fs.insert_tree("/root", json!({})).await;
+        // Create a png file that consists of a single white pixel
+        fs.insert_file(
+            "/root/image_1.png",
+            vec![
+                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
+                0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
+                0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
+                0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
+                0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
+            ],
+        )
+        .await;
+
+        let project = Project::test(fs, ["/root".as_ref()], cx).await;
+
+        let worktree_id =
+            cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
+
+        let project_path = ProjectPath {
+            worktree_id,
+            path: PathBuf::from("image_1.png").into(),
+        };
+
+        let (task1, task2) = project.update(cx, |project, cx| {
+            (
+                project.open_image(project_path.clone(), cx),
+                project.open_image(project_path.clone(), cx),
+            )
+        });
+
+        let image1 = task1.await.unwrap();
+        let image2 = task2.await.unwrap();
+
+        assert_eq!(image1, image2);
+    }
+}