Repository: sublimelinter/sublimelinter
Issue: SublimeLinter hardlocks Sublime Text with deadlock on rapid line content deletes with annotations 

This was originally submitted as sublimehq/sublime_text#5846

### Description of the bug

When using Neo​Vintageous (I did not test Vintageous), doing rapid ex commands with deletes on lines with annotations hard locks Sublime Text. I have been able to do this crash across multiple computers in safe mode

Please take a look at my reproduction steps for details. There is probably an even more minimal reproduction, but this is as far as I got since I didn't have a quick way of adding a lot of annotations otherwise.

Turning off annotation prevents the lockup for me; using ctrl+shift+k to delete lines does not lock up, and moving lines does not lock up. Doing a chain of ex commands with some deletes and annotation is currently my most faithful recreation.

This happens across multiple languages and files.

### Steps to reproduce

1. Start ST in safe mode
2. Install Package Control
3. Install `Neo​Vintageous`, `Sublimelinter`, `Sublime​Linter-contrib-yamllint`
4. Add the enclosed following `SublimeLinter.sublime-settings`
6. Open the enclosed `alacritty.yal` if yamllint is working almost every line should have a warning annotation. See screenshot enclosed
7. quickly type `<esc>jDjD`
8. Enjoy your pinwheel of doom


[Lockup replication files.zip](https://github.com/sublimehq/sublime_text/files/10514062/Lockup.replication.files.zip)


### Expected behavior

Sublime Text deletes to the end of the line and does not lock up

### Actual behavior

Sublime Text locks up and never recovers, requiring a force quit. ST is hanging waiting for the plugin to respond. The SublimeLinter is hanging [here](https://github.com/SublimeLinter/SublimeLinter/blob/0b540fa1aae0b925d1ab6c2856dcb1506b7f5883/highlight_view.py#L622). Looks like a deadlock.

### Sublime Text build number

4143

### Operating system & version

macos 13.1

### Additional information

This lockup happens both on my 1.4 GHz Quad-Core Intel Core i5 MacBook and my M1 Max MacStudio

### OpenGL context information

```plain
Intel
OpenGL Context Information:
  GL API Version: 4.1 INTEL-20.2.48
  GLSL Version: 4.10
  Vendor: Intel Inc.
  Renderer: Intel(R) Iris(TM) Plus Graphics 645

M1 Max
OpenGL Context Information:
  GL API Version: 4.1 Metal - 83
  GLSL Version: 4.10
  Vendor: Apple
  Renderer: Apple M1 Max
```


Stack trace from plugin host here
https://github.com/sublimehq/sublime_text/issues/5846#issuecomment-1409673359


Changed files:
sublime_linter.py, highlight_view.py, lint/util.py

diff --git a/highlight_view.py b/highlight_view.py
index 8350881..f187f1a 100644
--- a/highlight_view.py
+++ b/highlight_view.py
@@ -5,7 +5,6 @@ from itertools import chain
 from functools import partial
 import re
 import textwrap
-import threading
 import uuid
 
 import sublime
@@ -164,12 +163,8 @@ def highlight_linter_errors(views, filename, linter_name):
         squiggle_regions = ChainMap(
             {}, highlight_regions, hidden_highlight_regions  # type: ignore[arg-type]
         )  # type: Squiggles
-        draw(
-            view,
-            linter_name,
-            squiggle_regions,
-            gutter_regions,
-        )
+
+        draw(view, linter_name, squiggle_regions, gutter_regions)
         draw_phantoms(view)
 
 
@@ -185,6 +180,7 @@ def draw_phantoms(view):
     update_phantoms(view, phantoms)
 
 
+@util.ensure_on_ui_thread
 def update_phantoms(view, phantoms):
     with stable_viewport(view, phantoms):
         get_phantom_set(view).update(phantoms)
@@ -437,6 +433,7 @@ def undraw(view):
         erase_view_region(view, key)
 
 
+@util.ensure_on_ui_thread
 def draw(
     view,               # type: sublime.View
     linter_name,        # type: LinterName
@@ -585,7 +582,6 @@ class DemotePredicates:
 # --------------- ZOMBIE PROTECTION ---------------- #
 #    [¬º°]¬ [¬º°]¬  [¬º˚]¬  [¬º˙]* ─ ─ ─ ─ ─ ─ ─╦╤︻ #
 
-StorageLock = threading.Lock()
 # Just trying and catching `NameError` reuses the previous value or
 # "version" of this variable when hot-reloading
 try:
@@ -617,38 +613,38 @@ else:
         EVERSTORE = defaultdict(set)
 
 
+@util.assert_on_ui_thread
 def draw_view_region(view, key, regions):
     # type: (sublime.View, RegionKey, List[sublime.Region]) -> None
-    with StorageLock:
-        if SUBLIME_SUPPORTS_REGION_ANNOTATIONS and isinstance(key, Squiggle):
-            if key.annotation and key.visible():
-                annotations = {
-                    "annotations": [key.annotation],
-                    "annotation_color":
-                        view.style_for_scope(key.scope)["foreground"]
-                }
-            else:
-                annotations = {}
-            view.add_regions(
-                key,
-                regions,
-                key.scope,
-                key.icon,
-                key.flags,
-                **annotations
-            )
+    if SUBLIME_SUPPORTS_REGION_ANNOTATIONS and isinstance(key, Squiggle):
+        if key.annotation and key.visible():
+            annotations = {
+                "annotations": [key.annotation],
+                "annotation_color":
+                    view.style_for_scope(key.scope)["foreground"]
+            }
         else:
-            view.add_regions(key, regions, key.scope, key.icon, key.flags)
-        vid = view.id()
-        CURRENTSTORE[vid].add(key)
-        EVERSTORE[vid].add(key)
+            annotations = {}
+        view.add_regions(
+            key,
+            regions,
+            key.scope,
+            key.icon,
+            key.flags,
+            **annotations
+        )
+    else:
+        view.add_regions(key, regions, key.scope, key.icon, key.flags)
+    vid = view.id()
+    CURRENTSTORE[vid].add(key)
+    EVERSTORE[vid].add(key)
 
 
+@util.assert_on_ui_thread
 def erase_view_region(view, key):
     # type: (sublime.View, RegionKey) -> None
-    with StorageLock:
-        view.erase_regions(key)
-        CURRENTSTORE[view.id()].discard(key)
+    view.erase_regions(key)
+    CURRENTSTORE[view.id()].discard(key)
 
 
 def get_regions_keys(view):
@@ -656,11 +652,11 @@ def get_regions_keys(view):
     return frozenset(CURRENTSTORE.get(view.id(), set()))
 
 
+@util.assert_on_ui_thread
 def restore_from_everstore(view):
     # type: (sublime.View) -> None
-    with StorageLock:
-        vid = view.id()
-        CURRENTSTORE[vid] = EVERSTORE[vid].copy()
+    vid = view.id()
+    CURRENTSTORE[vid] = EVERSTORE[vid].copy()
 
 
 class ZombieController(sublime_plugin.EventListener):
@@ -703,6 +699,7 @@ class RevisitErrorRegions(sublime_plugin.EventListener):
         sublime.set_timeout_async(lambda: maybe_update_error_store(view))
 
 
+@util.ensure_on_ui_thread
 def revalidate_regions(view):
     # type: (sublime.View) -> None
     vid = view.id()
@@ -779,6 +776,7 @@ def maybe_update_error_store(view):
 
     changed = False
     new_errors = []
+    regions_to_erase = []
     for error in errors:
         uid = error['uid']
         key = uid_key_map.get(uid, None)
@@ -798,7 +796,7 @@ def maybe_update_error_store(view):
             # zero length (and moved to a different line at col 0).
             # It is useless now so we remove the error by not
             # copying it.
-            erase_view_region(view, key)
+            regions_to_erase.append(key)
             continue
 
         line, start = view.rowcol(region.begin())
@@ -811,10 +809,17 @@ def maybe_update_error_store(view):
         new_errors.append(error)
 
     if changed:
+        _erase_view_regions(view, regions_to_erase)
         persist.file_errors[filename] = new_errors
         events.broadcast('updated_error_positions', {'filename': filename})
 
 
+@util.ensure_on_ui_thread
+def _erase_view_regions(view, keys):
+    for key in keys:
+        erase_view_region(view, key)
+
+
 class IdleViewController(sublime_plugin.EventListener):
     def on_activated_async(self, active_view):
         previous_view = State['active_view']
@@ -874,6 +879,7 @@ def set_idle(view, idle):
         toggle_demoted_regions(view, idle)
 
 
+@util.ensure_on_ui_thread
 def toggle_demoted_regions(view, show):
     # type: (sublime.View, bool) -> None
     vid = view.id()
@@ -917,6 +923,7 @@ class sublime_linter_toggle_highlights(sublime_plugin.WindowCommand):
 HIDDEN_SCOPE = ''
 
 
+@util.ensure_on_ui_thread
 def toggle_all_regions(view, show):
     # type: (sublime.View, bool) -> None
     region_keys = get_regions_keys(view)
diff --git a/lint/util.py b/lint/util.py
index 9a66edb..cb7a2fb 100644
--- a/lint/util.py
+++ b/lint/util.py
@@ -1,7 +1,7 @@
 """This module provides general utility methods."""
 from collections import ChainMap
 from contextlib import contextmanager
-from functools import lru_cache, wraps
+from functools import lru_cache, partial, wraps
 import locale
 import logging
 import os
@@ -18,7 +18,10 @@ from .const import IS_ENABLED_SWITCH
 
 MYPY = False
 if MYPY:
-    from typing import Iterator, List, MutableMapping, Optional, TypeVar, Union
+    from typing import (
+        Callable, Iterator, List, MutableMapping, Optional, TypeVar, Union)
+    from typing_extensions import ParamSpec
+    P = ParamSpec('P')
     T = TypeVar('T')
 
 
@@ -28,7 +31,7 @@ logger = logging.getLogger(__name__)
 STREAM_STDOUT = 1
 STREAM_STDERR = 2
 STREAM_BOTH = STREAM_STDOUT + STREAM_STDERR
-
+UI_THREAD_NAME = None  # type: Optional[str]
 ANSI_COLOR_RE = re.compile(r'\\033\\[[0-9;]*m')
 
 
@@ -37,6 +40,60 @@ def on_settings_changed(settings, **kwargs):
     get_augmented_path.cache_clear()
 
 
+def determine_thread_names():
+    def callback():
+        global UI_THREAD_NAME
+        UI_THREAD_NAME = threading.current_thread().name
+    sublime.set_timeout(callback)
+
+
+def ensure_on_ui_thread(fn):
+    # type: (Callable[P, T]) -> Callable[P, None]
+    """Decorate a `fn` to always run on the UI thread
+
+    Check at runtime on which thread the code runs and maybe
+    enqueue a task on the UI thread.  If already on the UI
+    thread run `fn` immediately and blocking.  Otherwise
+    return immediately.
+    """
+    @wraps(fn)
+    def wrapped(*args, **kwargs):
+        # type: (P.args, P.kwargs) -> None
+        _ensure_on_ui(fn, *args, **kwargs)
+    return wrapped
+
+
+def assert_on_ui_thread(fn):
+    # type: (Callable[P, T]) -> Callable[P, T]
+    @wraps(fn)
+    def wrapped(*args, **kwargs):
+        # type: (P.args, P.kwargs) -> T
+        if it_runs_on_ui():
+            return fn(*args, **kwargs)
+        msg = "'{}' must be called from the UI thread".format(fn.__name__)
+        sublime.status_message("RuntimeError: {}".format(msg))
+        raise RuntimeError(msg)
+    return wrapped
+
+
+def _ensure_on_ui(fn, *args, **kwargs):
+    # type: (Callable[P, T], P.args, P.kwargs) -> None
+    if it_runs_on_ui():
+        fn(*args, **kwargs)
+    else:
+        enqueue_on_ui(fn, *args, **kwargs)
+
+
+def it_runs_on_ui():
+    # type: () -> bool
+    return threading.current_thread().name == UI_THREAD_NAME
+
+
+def enqueue_on_ui(fn, *args, **kwargs):
+    # type: (Callable[P, T], P.args, P.kwargs) -> None
+    sublime.set_timeout(partial(fn, *args, **kwargs))
+
+
 @contextmanager
 def print_runtime(message):
     start_time = time.perf_counter()
diff --git a/sublime_linter.py b/sublime_linter.py
index ab4235e..55a8fb5 100644
--- a/sublime_linter.py
+++ b/sublime_linter.py
@@ -72,6 +72,7 @@ def plugin_loaded():
     persist.kill_switch = False
     events.broadcast('plugin_loaded')
     persist.settings.load()
+    util.determine_thread_names()
     logger.info("debug mode: on")
     logger.info("version: " + util.get_sl_version())