Fix duplicate hints in multi buffer excerpts during editing and scrolling (#41143)

Kirill Bulatov created

Follow-up of https://github.com/zed-industries/zed/pull/40183
Closes https://github.com/zed-industries/zed/issues/24798

Release Notes:

- N/A

Change summary

crates/editor/src/inlays/inlay_hints.rs | 328 +++++++++++++++++++++++++-
1 file changed, 306 insertions(+), 22 deletions(-)

Detailed changes

crates/editor/src/inlays/inlay_hints.rs 🔗

@@ -1,7 +1,6 @@
 use std::{
     collections::hash_map,
     ops::{ControlFlow, Range},
-    sync::Arc,
     time::Duration,
 };
 
@@ -15,7 +14,6 @@ use language::{
 };
 use lsp::LanguageServerId;
 use multi_buffer::{Anchor, ExcerptId, MultiBufferSnapshot};
-use parking_lot::Mutex;
 use project::{
     HoverBlock, HoverBlockKind, InlayHintLabel, InlayHintLabelPartTooltip, InlayHintTooltip,
     InvalidationStrategy, ResolveState,
@@ -53,6 +51,7 @@ pub struct LspInlayHintData {
     append_debounce: Option<Duration>,
     hint_refresh_tasks: HashMap<BufferId, HashMap<Vec<Range<BufferRow>>, Vec<Task<()>>>>,
     hint_chunk_fetched: HashMap<BufferId, (Global, HashSet<Range<BufferRow>>)>,
+    invalidate_hints_for_buffers: HashSet<BufferId>,
     pub added_hints: HashMap<InlayId, Option<InlayHintKind>>,
 }
 
@@ -65,6 +64,7 @@ impl LspInlayHintData {
             hint_refresh_tasks: HashMap::default(),
             added_hints: HashMap::default(),
             hint_chunk_fetched: HashMap::default(),
+            invalidate_hints_for_buffers: HashSet::default(),
             invalidate_debounce: debounce_value(settings.edit_debounce_ms),
             append_debounce: debounce_value(settings.scroll_debounce_ms),
             allowed_hint_kinds: settings.enabled_inlay_hint_kinds(),
@@ -101,6 +101,7 @@ impl LspInlayHintData {
         self.hint_refresh_tasks.clear();
         self.hint_chunk_fetched.clear();
         self.added_hints.clear();
+        self.invalidate_hints_for_buffers.clear();
     }
 
     /// Checks inlay hint settings for enabled hint kinds and general enabled state.
@@ -289,7 +290,7 @@ impl Editor {
         };
 
         let mut visible_excerpts = self.visible_excerpts(cx);
-        let mut all_affected_buffers = HashSet::default();
+        let mut invalidate_hints_for_buffers = HashSet::default();
         let ignore_previous_fetches = match reason {
             InlayHintRefreshReason::ModifiersChanged(_)
             | InlayHintRefreshReason::Toggle(_)
@@ -307,7 +308,7 @@ impl Editor {
                     return;
                 };
 
-                all_affected_buffers.extend(
+                invalidate_hints_for_buffers.extend(
                     self.buffer()
                         .read(cx)
                         .all_buffers()
@@ -322,7 +323,7 @@ impl Editor {
                         }),
                 );
 
-                semantics_provider.invalidate_inlay_hints(&all_affected_buffers, cx);
+                semantics_provider.invalidate_inlay_hints(&invalidate_hints_for_buffers, cx);
                 visible_excerpts.retain(|_, (visible_buffer, _, _)| {
                     visible_buffer.read(cx).language() == Some(&affected_language)
                 });
@@ -338,6 +339,9 @@ impl Editor {
         if invalidate_cache.should_invalidate() {
             inlay_hints.clear();
         }
+        inlay_hints
+            .invalidate_hints_for_buffers
+            .extend(invalidate_hints_for_buffers);
 
         let mut buffers_to_query = HashMap::default();
         for (excerpt_id, (buffer, buffer_version, visible_range)) in visible_excerpts {
@@ -364,7 +368,6 @@ impl Editor {
             visible_excerpts.ranges.push(buffer_anchor_range);
         }
 
-        let all_affected_buffers = Arc::new(Mutex::new(all_affected_buffers));
         for (buffer_id, visible_excerpts) in buffers_to_query {
             let Some(buffer) = multi_buffer.read(cx).buffer(buffer_id) else {
                 continue;
@@ -396,7 +399,6 @@ impl Editor {
                             ignore_previous_fetches,
                             debounce,
                             visible_excerpts,
-                            all_affected_buffers.clone(),
                             cx,
                         ));
                     }
@@ -408,7 +410,6 @@ impl Editor {
                         ignore_previous_fetches,
                         debounce,
                         visible_excerpts,
-                        all_affected_buffers.clone(),
                         cx,
                     ));
                 }
@@ -765,7 +766,6 @@ impl Editor {
         query_version: Global,
         invalidate_cache: InvalidationStrategy,
         new_hints: Vec<(Range<BufferRow>, anyhow::Result<CacheInlayHints>)>,
-        all_affected_buffers: Arc<Mutex<HashSet<BufferId>>>,
         cx: &mut Context<Self>,
     ) {
         let visible_inlay_hint_ids = self
@@ -829,21 +829,16 @@ impl Editor {
             })
             .collect::<Vec<_>>();
 
-        // We need to invalidate excerpts all buffers with the same language, do that once only, after first new data chunk is inserted.
-        let all_other_affected_buffers = all_affected_buffers
-            .lock()
-            .drain()
-            .filter(|id| buffer_id != *id)
-            .collect::<HashSet<_>>();
-        if !all_other_affected_buffers.is_empty() {
+        let invalidate_hints_for_buffers =
+            std::mem::take(&mut inlay_hints.invalidate_hints_for_buffers);
+        if !invalidate_hints_for_buffers.is_empty() {
             hints_to_remove.extend(
                 self.visible_inlay_hints(cx)
                     .iter()
                     .filter(|inlay| {
-                        inlay
-                            .position
-                            .buffer_id
-                            .is_none_or(|buffer_id| all_other_affected_buffers.contains(&buffer_id))
+                        inlay.position.buffer_id.is_none_or(|buffer_id| {
+                            invalidate_hints_for_buffers.contains(&buffer_id)
+                        })
                     })
                     .map(|inlay| inlay.id),
             );
@@ -867,7 +862,6 @@ fn spawn_editor_hints_refresh(
     ignore_previous_fetches: bool,
     debounce: Option<Duration>,
     buffer_excerpts: VisibleExcerpts,
-    all_affected_buffers: Arc<Mutex<HashSet<BufferId>>>,
     cx: &mut Context<'_, Editor>,
 ) -> Task<()> {
     cx.spawn(async move |editor, cx| {
@@ -901,7 +895,6 @@ fn spawn_editor_hints_refresh(
                     query_version,
                     invalidate_cache,
                     new_hints,
-                    all_affected_buffers,
                     cx,
                 );
             })
@@ -3658,6 +3651,297 @@ let c = 3;"#
             .unwrap();
     }
 
+    #[gpui::test]
+    async fn test_invalidation_and_addition_race(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |settings| {
+            settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
+                enabled: Some(true),
+                ..InlayHintSettingsContent::default()
+            })
+        });
+
+        let fs = FakeFs::new(cx.background_executor.clone());
+        fs.insert_tree(
+            path!("/a"),
+            json!({
+                "main.rs": r#"fn main() {
+                    let x = 1;
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    ////
+                    let x = "2";
+                }
+"#,
+                "lib.rs": r#"fn aaa() {
+                    let aa = 22;
+                }
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+                //
+
+                fn bb() {
+                    let bb = 33;
+                }
+"#
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/a").as_ref()], cx).await;
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        let language = rust_lang();
+        language_registry.add(language);
+
+        let requests_count = Arc::new(AtomicUsize::new(0));
+        let closure_requests_count = requests_count.clone();
+        let mut fake_servers = language_registry.register_fake_lsp(
+            "Rust",
+            FakeLspAdapter {
+                capabilities: lsp::ServerCapabilities {
+                    inlay_hint_provider: Some(lsp::OneOf::Left(true)),
+                    ..lsp::ServerCapabilities::default()
+                },
+                initializer: Some(Box::new(move |fake_server| {
+                    let requests_count = closure_requests_count.clone();
+                    fake_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
+                        move |params, _| {
+                            let requests_count = requests_count.clone();
+                            async move {
+                                requests_count.fetch_add(1, Ordering::Release);
+                                if params.text_document.uri
+                                    == lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap()
+                                {
+                                    Ok(Some(vec![
+                                        lsp::InlayHint {
+                                            position: lsp::Position::new(1, 9),
+                                            label: lsp::InlayHintLabel::String(": i32".to_owned()),
+                                            kind: Some(lsp::InlayHintKind::TYPE),
+                                            text_edits: None,
+                                            tooltip: None,
+                                            padding_left: None,
+                                            padding_right: None,
+                                            data: None,
+                                        },
+                                        lsp::InlayHint {
+                                            position: lsp::Position::new(19, 9),
+                                            label: lsp::InlayHintLabel::String(": i33".to_owned()),
+                                            kind: Some(lsp::InlayHintKind::TYPE),
+                                            text_edits: None,
+                                            tooltip: None,
+                                            padding_left: None,
+                                            padding_right: None,
+                                            data: None,
+                                        },
+                                    ]))
+                                } else if params.text_document.uri
+                                    == lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap()
+                                {
+                                    Ok(Some(vec![
+                                        lsp::InlayHint {
+                                            position: lsp::Position::new(1, 10),
+                                            label: lsp::InlayHintLabel::String(": i34".to_owned()),
+                                            kind: Some(lsp::InlayHintKind::TYPE),
+                                            text_edits: None,
+                                            tooltip: None,
+                                            padding_left: None,
+                                            padding_right: None,
+                                            data: None,
+                                        },
+                                        lsp::InlayHint {
+                                            position: lsp::Position::new(29, 10),
+                                            label: lsp::InlayHintLabel::String(": i35".to_owned()),
+                                            kind: Some(lsp::InlayHintKind::TYPE),
+                                            text_edits: None,
+                                            tooltip: None,
+                                            padding_left: None,
+                                            padding_right: None,
+                                            data: None,
+                                        },
+                                    ]))
+                                } else {
+                                    panic!("Unexpected file path {:?}", params.text_document.uri);
+                                }
+                            }
+                        },
+                    );
+                })),
+                ..FakeLspAdapter::default()
+            },
+        );
+
+        let (buffer_1, _handle_1) = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx)
+            })
+            .await
+            .unwrap();
+        let (buffer_2, _handle_2) = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer_with_lsp(path!("/a/lib.rs"), cx)
+            })
+            .await
+            .unwrap();
+        let multi_buffer = cx.new(|cx| {
+            let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
+            multibuffer.push_excerpts(
+                buffer_2.clone(),
+                [
+                    ExcerptRange::new(Point::new(0, 0)..Point::new(10, 0)),
+                    ExcerptRange::new(Point::new(23, 0)..Point::new(34, 0)),
+                ],
+                cx,
+            );
+            multibuffer.push_excerpts(
+                buffer_1.clone(),
+                [
+                    ExcerptRange::new(Point::new(0, 0)..Point::new(10, 0)),
+                    ExcerptRange::new(Point::new(13, 0)..Point::new(23, 0)),
+                ],
+                cx,
+            );
+            multibuffer
+        });
+
+        let editor = cx.add_window(|window, cx| {
+            let mut editor =
+                Editor::for_multibuffer(multi_buffer, Some(project.clone()), window, cx);
+            editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+                s.select_ranges([Point::new(3, 3)..Point::new(3, 3)])
+            });
+            editor
+        });
+
+        let fake_server = fake_servers.next().await.unwrap();
+        cx.executor().advance_clock(Duration::from_millis(100));
+        cx.executor().run_until_parked();
+
+        editor
+            .update(cx, |editor, _window, cx| {
+                assert_eq!(
+                    vec![
+                        ": i32".to_string(),
+                        ": i33".to_string(),
+                        ": i34".to_string(),
+                        ": i35".to_string(),
+                    ],
+                    sorted_cached_hint_labels(editor, cx),
+                );
+                assert_eq!(
+                    vec![
+                        ": i34".to_string(),
+                        ": i35".to_string(),
+                        ": i32".to_string(),
+                        ": i33".to_string(),
+                    ],
+                    visible_hint_labels(editor, cx),
+                    "lib.rs is added before main.rs , so its excerpts should be visible first"
+                );
+            })
+            .unwrap();
+        assert_eq!(
+            requests_count.load(Ordering::Acquire),
+            2,
+            "Should have queried hints once per each file"
+        );
+
+        // Scroll all the way down so the 1st buffer is out of sight.
+        // The selection is on the 1st buffer still.
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.scroll_screen(&ScrollAmount::Line(88.0), window, cx);
+            })
+            .unwrap();
+        // Emulate a language server refresh request, coming in the background..
+        editor
+            .update(cx, |editor, _, cx| {
+                editor.refresh_inlay_hints(
+                    InlayHintRefreshReason::RefreshRequested(fake_server.server.server_id()),
+                    cx,
+                );
+            })
+            .unwrap();
+        // Edit the 1st buffer while scrolled down and not seeing that.
+        // The edit will auto scroll to the edit (1st buffer).
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.handle_input("a", window, cx);
+            })
+            .unwrap();
+        // Add more racy additive hint tasks.
+        editor
+            .update(cx, |editor, window, cx| {
+                editor.scroll_screen(&ScrollAmount::Line(0.2), window, cx);
+            })
+            .unwrap();
+
+        cx.executor().advance_clock(Duration::from_millis(1000));
+        cx.executor().run_until_parked();
+        editor
+            .update(cx, |editor, _window, cx| {
+                assert_eq!(
+                    vec![
+                        ": i32".to_string(),
+                        ": i33".to_string(),
+                        ": i34".to_string(),
+                        ": i35".to_string(),
+                    ],
+                    sorted_cached_hint_labels(editor, cx),
+                    "No hint changes/duplicates should occur in the cache",
+                );
+                assert_eq!(
+                    vec![
+                        ": i34".to_string(),
+                        ": i35".to_string(),
+                        ": i32".to_string(),
+                        ": i33".to_string(),
+                    ],
+                    visible_hint_labels(editor, cx),
+                    "No hint changes/duplicates should occur in the editor excerpts",
+                );
+            })
+            .unwrap();
+        assert_eq!(
+            requests_count.load(Ordering::Acquire),
+            4,
+            "Should have queried hints once more per each file, after editing the file once"
+        );
+    }
+
     pub(crate) fn init_test(cx: &mut TestAppContext, f: impl Fn(&mut AllLanguageSettingsContent)) {
         cx.update(|cx| {
             let settings_store = SettingsStore::test(cx);