Repository: sublimelsp/LSP

Issue:
Diagnostics get out of sync
During editing of JSON file the diagnostics become out of sync with the real state of the file. Happened before in other servers and files also.

```
	"diagnostics_delay_ms": 2000,
	"diagnostics_additional_delay_auto_complete_ms": 1000,
```
`st4000-exploration` (b1a1fa79a71d215dde2d6c396b559561feaeef5f).

```
::  -> LSP-json textDocument/didChange: {'textDocument': {'uri': 'file:///project/template/.lokalise.json', 'version': 6}, 'contentChanges': [{'text': '        "filter_langs": ["bg", "de", "en", "es", "fr", "it", "no", "pl", "pt", "pt_BR"],\n', 'range': {'end': {'character': 0, 'line': 7}, 'start': {'character': 0, 'line': 7}}, 'rangeLength': 0}]}
:: <-  LSP-json textDocument/publishDiagnostics: {'diagnostics': [{'message': 'Duplicate object key', 'source': 'json', 'code': 520, 'range': {'end': {'character': 22, 'line': 8}, 'start': {'character': 8, 'line': 8}}, 'severity': 2}, {'message': 'Duplicate object key', 'source': 'json', 'code': 520, 'range': {'end': {'character': 22, 'line': 7}, 'start': {'character': 8, 'line': 7}}, 'severity': 2}], 'uri': 'file:///project/template/.lokalise.json'}
::  -> LSP-json textDocument/didChange: {'textDocument': {'uri': 'file:///project/template/.lokalise.json', 'version': 7}, 'contentChanges': [{'text': '', 'range': {'end': {'character': 0, 'line': 9}, 'start': {'character': 0, 'line': 8}}, 'rangeLength': 86}]}
:: --> LSP-json textDocument/documentColor(10): {'textDocument': {'uri': 'file:///project/template/.lokalise.json'}}
:: <<< LSP-json 10: []
:: <-  LSP-json textDocument/publishDiagnostics: {'diagnostics': [], 'uri': 'file:///project/template/.lokalise.json'}
```

![Screenshot 2020-07-20 at 12 53 10](https://user-images.githubusercontent.com/153197/87930226-f94a1900-ca87-11ea-9792-d52aa9137e18.png)

As clear from the log, the last state from the server reported no diagnostics but ST is still showing the one that was reported before that.


Discussion:
Iam having a similar issue with LSP-metals
Are you also using non-default value for `diagnostics_delay_ms`?
> Are you also using non-default value for `diagnostics_delay_ms`?

No I am using default values for both settings. 

LSP isn't showing `textDocument/publishDiagnostics`, sometime the panel gets updated after compilation sometime not.

These are my settings: 
```json
  "auto_show_diagnostics_panel": "never",
  "show_diagnostics_in_view_status": false,
  "show_diagnostics_count_in_view_status": true,
  "diagnostics_panel_include_severity_level": 1,
```
in the server logs I see clearly: 
```
:: <-  LSP-metals textDocument/publishDiagnostics: {'diagnostics': [{'severity': 1,
```


I can create a separate issue if you think it isn't related.
Is there a `"version"` present in the diagnostics payload? I have a hunch that the problem is related to the diagnostics version.
Not in my case.
I also noticed that the counter in the status Bar and the diagnostic panel are out sync. Meaning Errors count is 0 but diagnostic panel still reports an error.

> I also noticed that the counter in the status Bar and the diagnostic panel are out sync. Meaning Errors count is 0 but diagnostic panel still reports an error.

The diagnostics count in the status bar shows the count of the current file, the panel shows all diagnostics. Are the files the same?
Oh, I realize now that that status bar count was a global error count, not per-view. https://github.com/sublimelsp/LSP/blob/e7dac40247d68398fc24d1b19b1565d05e472ae8/plugin/diagnostics.py#L301
> Oh, I realize now that that status bar count was a global error count, not per-view.

I think it was always a global count. I often check it before deciding to open or not the diagnostic panel 
I've created an issue for it #1201
The discussion in https://github.com/sublimelsp/LSP/pull/1203 shows that there is a problem with debounced calls ~~not being sequential (debouncing f at time t1 and debouncing f at time t2 might result in the second f being called earlier, if that makes sense)~~.

But @ayoub-benali notes that his `diagnostics_delay_ms` setting is zero, so that puzzles me a bit.
> debouncing f at time t1 and debouncing f at time t2 might result in the second f being called earlier, if that makes sense

The two denounced calls would be sequential. In that particular case, the second call is not debounced so it runs before the first one that was debounced.

> But @ayoub-benali notes that his diagnostics_delay_ms setting is zero, so that puzzles me a bit.

Right. I don't see how that particular issue could happen in that case.
So even after https://github.com/sublimelsp/LSP/pull/1202 was merged I still have the same issue, although it seems random and I can't reproduce the issue easily. 
The error count and diagnostic panel are respectively 0 and empty but looking at the server logs I see 
`:: <-  LSP-metals textDocument/publishDiagnostics: {'diagnostics'` with severity 1

So both issues have probably the same source.  
  
Note that the fix for the issue I've reported has not been made yet.

Resolving patch:
diff --git a/plugin/core/types.py b/plugin/core/types.py
--- a/plugin/core/types.py
+++ b/plugin/core/types.py
@@ -1,6 +1,7 @@
 from .collections import DottedDict
 from .logging import debug
 from .typing import Optional, List, Dict, Generator, Callable
+from threading import RLock
 import contextlib
 import functools
 import sublime
@@ -51,6 +52,43 @@ def _settings_style_to_add_regions_flag(style: str) -> int:
     return flags
 
 
+class Debouncer:
+
+    def __init__(self) -> None:
+        self._current_id = -1
+        self._next_id = 0
+        self._current_id_lock = RLock()
+
+    def debounce(self, f: Callable[[], None], timeout_ms: int = 0, condition: Callable[[], bool] = lambda: True,
+                 async_thread: bool = False) -> None:
+        """
+        Possibly run a function at a later point in time, either on the async thread or on the main thread.
+
+        :param      f:             The function to possibly run
+        :param      timeout_ms:    The time in milliseconds after which to possibly to run the function
+        :param      condition:     The condition that must evaluate to True in order to run the funtion
+        :param      async_thread:  If true, run the function on the async worker thread, otherwise run
+                                   the function on the main thread
+        """
+
+        def run(debounce_id: int) -> None:
+            with self._current_id_lock:
+                if debounce_id != self._current_id:
+                    return
+            if condition():
+                f()
+
+        runner = sublime.set_timeout_async if async_thread else sublime.set_timeout
+        with self._current_id_lock:
+            current_id = self._current_id = self._next_id
+        self._next_id += 1
+        runner(lambda: run(current_id), timeout_ms)
+
+    def cancel_pending(self) -> None:
+        with self._current_id_lock:
+            self._current_id = -1
+
+
 class Settings:
 
     def __init__(self) -> None:
diff --git a/plugin/session_buffer.py b/plugin/session_buffer.py
--- a/plugin/session_buffer.py
+++ b/plugin/session_buffer.py
@@ -5,6 +5,7 @@
 from .core.sessions import SessionViewProtocol
 from .core.settings import settings
 from .core.types import debounced
+from .core.types import Debouncer
 from .core.typing import Any, Iterable, Optional, List, Dict
 from .core.views import DIAGNOSTIC_SEVERITY
 from .core.views import did_change
@@ -75,6 +76,7 @@ def __init__(self, session_view: SessionViewProtocol, buffer_id: int, language_i
         self.total_errors = 0
         self.total_warnings = 0
         self.should_show_diagnostics_panel = False
+        self.diagnostics_debouncer = Debouncer()
 
     def __del__(self) -> None:
         mgr = self.session.manager()
@@ -217,6 +219,8 @@ def present() -> None:
                 should_show_diagnostics_panel
             )
 
+        self.diagnostics_debouncer.cancel_pending()
+
         if not bool(diagnostics) and not self.diagnostics_are_visible:
             # Nothing was previously visible, and nothing will become visible. So do nothing.
             pass
@@ -231,7 +235,7 @@ def present() -> None:
             if delay_in_seconds <= 0.0:
                 present()
             else:
-                debounced(
+                self.diagnostics_debouncer.debounce(
                     present,
                     timeout_ms=int(1000.0 * delay_in_seconds),
                     condition=lambda: self.view.is_valid() and self.view.change_count() == diagnostics_version,


Test patch: