Skip to content
Draft
Show file tree
Hide file tree
Changes from 105 commits
Commits
Show all changes
106 commits
Select commit Hold shift + click to select a range
699dad3
Test both TCP modes
rwols Apr 13, 2026
908c849
Use enum for 'Mode' in server.py
rwols Apr 18, 2026
b4f0420
Make init_options optional
rwols Apr 18, 2026
76f3c3f
WIP add asyncio to transports
rwols Apr 18, 2026
45d7ea6
Test both TCP modes
rwols Apr 13, 2026
e8fae30
Use enum for 'Mode' in server.py
rwols Apr 18, 2026
c511cec
Make init_options optional
rwols Apr 18, 2026
7438d4a
Use StrEnum
rwols Apr 18, 2026
6a104b0
Merge branch 'chore/add-tests' into feat/asyncio
rwols Apr 19, 2026
d59bb1c
More work on adding asyncio
rwols Apr 20, 2026
c0bea09
Merge branch 'main' into feat/asyncio
rwols Apr 20, 2026
c1fc59c
More work towards asyncio
rwols Apr 22, 2026
e35b611
Remove accidental merge conflict changes
rwols Apr 22, 2026
6cad098
Remove code in server.py, I really screwed up the merge but will reba…
rwols Apr 22, 2026
1597782
refactor Promise.__await__
rwols Apr 22, 2026
cc642f4
Add more comments to _SetTimeoutAsyncExecutor
rwols Apr 22, 2026
cea0e99
Rename Session._invoke_views -> Session._invoke_views_async
rwols Apr 22, 2026
3d30ad4
More work towards asyncio
rwols Apr 28, 2026
472705b
debugging sublime_aio.ViewEventListener
rwols Apr 28, 2026
ea34714
Merge branch 'main' into feat/asyncio
rwols May 2, 2026
069ed50
Fix calls
rwols May 2, 2026
776749a
More fix calls
rwols May 2, 2026
0227877
More work. Some diagnostics show intermittently. Request logic is not…
rwols May 3, 2026
2649a6f
Tweaks to pull diagnostics handling
rwols May 3, 2026
54af917
Restore method names
rwols May 5, 2026
1a4589c
Forgot import
rwols May 5, 2026
900f721
Restore more method names
rwols May 5, 2026
35621cf
ResponseException is not needed, we already have Error
rwols May 8, 2026
dbf4d7d
Merge branch 'main' into feat/asyncio
rwols May 8, 2026
f3b2f84
More rename reverts
rwols May 8, 2026
a9c1692
Add more tracing for understanding why requests are not resolving
rwols May 8, 2026
7004df5
Start fixing up 'goto' functionality
rwols May 9, 2026
a876949
Fix:
rwols May 10, 2026
d27aa93
debugging
rwols May 10, 2026
341ff99
We have something working
rwols May 10, 2026
4ed4e3f
Start fixing diagnostic errors because diagnostics work
rwols May 11, 2026
7aba65e
Remove trace() calls
rwols May 12, 2026
e3ccba2
Remove trace() calls
rwols May 12, 2026
c2c2a8c
Remove trace() calls
rwols May 12, 2026
af561a8
Fixups
rwols May 12, 2026
7260c90
Remove trace() calls and fixup type hints
rwols May 12, 2026
a60ddd2
Reintroduce request_code_actions_async for SessionBuffer
rwols May 12, 2026
23210e3
Consolidate sublime_aio & asyncio functions/classes in plugin/core/ai…
rwols May 12, 2026
73ed644
get_session_buffer_for_uri -> get_session_buffer_for_uri_async
rwols May 12, 2026
6d6e772
Session.session_buffers -> Session.session_buffers_async
rwols May 12, 2026
a84f3dd
_invoke_views_async -> _invoke_views
rwols May 12, 2026
f7702c2
Fix notifications being logged twice
rwols May 12, 2026
bb51929
Fix LspCheckApplicableCommand... I think
rwols May 12, 2026
7d58862
Fix errors in WindowManager.start, and allow async version of LspPlug…
rwols May 12, 2026
e2119a1
Print exceptions from coroutines started from `run_coroutine_threadsafe`
rwols May 13, 2026
ce27231
Ensure plugin_unloaded works as expected
rwols May 13, 2026
a4aba53
Merge branch 'main' into feat/asyncio
rwols May 13, 2026
c215234
Fixup incorrect (old) usage of Session.send_request_async
rwols May 13, 2026
ae081e3
Remove trace() calls from sessions.py
rwols May 13, 2026
e4d3324
Merge branch 'main' into feat/asyncio
rwols May 14, 2026
6e5ca85
Fix type errors in sessions.py
rwols May 14, 2026
4866feb
Review all sublime.set_timeout_async call sites
rwols May 14, 2026
73a658a
Merge branch 'main' into feat/asyncio
rwols May 14, 2026
8a6de63
asyncio.Future, async functions, and Promises are all just Awaitables
rwols May 14, 2026
fcb8717
Fix process args
rwols May 14, 2026
8997d51
Fix most type errors, except for tooling.py
rwols May 14, 2026
5943571
Fix: document link was requested before didOpen
rwols May 14, 2026
7485b90
Rename Files: open files sequentially, as it as before
rwols May 14, 2026
edd3ccd
apply_text_edits: wait at least one UI frame
rwols May 14, 2026
91af0e0
LspPlugin.prefer_async_on_pre_start -> LspPlugin.use_asyncio
rwols May 14, 2026
da1fc1d
Invoke LspPlugin.on_initialize after the `initialized` notification
rwols May 14, 2026
34697be
Update tooling.py for asyncio
rwols May 15, 2026
aa37898
Remove unused imports
rwols May 15, 2026
9335c7d
Fix 'TCP client' mode
rwols May 15, 2026
afde9fb
Compatibility with python 3.8
rwols May 15, 2026
0795ee4
Fix missing import for type checking
rwols May 15, 2026
4659e8d
Add stubs/sublime_aio.pyi
rwols May 15, 2026
76aeba1
Fix formatting
rwols May 15, 2026
66bd43a
Fix all remaining lint errors
rwols May 15, 2026
a143bdf
Fix interface method (why isn't this reported as an error by either p…
rwols May 15, 2026
69fd44d
Fixup interface method of `Manager` (why isn't this reported by eithe…
rwols May 15, 2026
c2a9e30
Fixup wm.handle_show_message: it's not async
rwols May 15, 2026
8835214
Add @override to all methods in WindowManager that implement an inter…
rwols May 15, 2026
668846c
Turn off @deprecation warnings
rwols May 15, 2026
c6a89b1
Fix lint warnings
rwols May 15, 2026
9d8c1b3
The return type of the `Window.handle_show_message` interface method …
rwols May 15, 2026
d271624
Fix reference to task object
rwols May 16, 2026
68b8945
Add function: exceptions_log
rwols May 16, 2026
a3c88ec
Catch possible exception when draining the stream writer
rwols May 16, 2026
3915ca6
Add functions aclosing, gather_and_flatten_exceptions, TaskContainer.…
rwols May 16, 2026
82973a5
Fix for python 3.8 runtime regarding opening files lock
rwols May 16, 2026
1ae40fe
Fixes for python 3.8 runtime, better CancellableInflightStreamingRequ…
rwols May 16, 2026
45bbb05
Fix LSP: Rename
rwols May 16, 2026
998ac1c
Comment out the debug print in the tranports.py because I'm feeling c…
rwols May 16, 2026
9c761d1
Merge branch 'main' into feat/asyncio
rwols May 16, 2026
056359b
LspPlugin.use_asyncio() -> LspPlugin.use_asyncio
rwols May 16, 2026
aca3b34
Fixup incorrect merge resolution in api.py
rwols May 16, 2026
1a6448b
I don't know how this got here.
rwols May 16, 2026
dd2b791
Odds and ends in sessions.py
rwols May 16, 2026
ff9cfd7
Odds and ends: make `@requires_session` compatible with coroutine fun…
rwols May 16, 2026
5e3af9c
WIP refactor tests
rwols May 17, 2026
d91eeda
Convert unit/integration tests to asyncio
rwols May 18, 2026
8676dc4
Fix bugs revealed by tests
rwols May 18, 2026
c6efba4
Merge branch 'main' into feat/asyncio
rwols May 18, 2026
2c2e7b3
Merge branch 'main' into feat/asyncio
rwols May 18, 2026
119fa99
Add runtime check for accidental coroutine continuations
rwols May 18, 2026
9e35cc4
In the process of fixing bugs due to tests revealing bugs
rwols May 19, 2026
8c83c96
Re-introduce classSetUp and classTearDown
rwols May 20, 2026
691d8ea
Fixes for macOS
rwols May 21, 2026
0aa553b
Merge branch 'main' into feat/asyncio
rwols May 21, 2026
e9d65dd
Fixes after merge
rwols May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
from .plugin.configuration import LspDisableLanguageServerInProjectCommand
from .plugin.configuration import LspEnableLanguageServerGloballyCommand
from .plugin.configuration import LspEnableLanguageServerInProjectCommand
from .plugin.core.aio import run_coroutine_threadsafe
from .plugin.core.constants import ST_VERSION
from .plugin.core.css import load as load_css
from .plugin.core.open import g_opening_files
from .plugin.core.open import get_opening_files_lock
from .plugin.core.panels import PanelName
from .plugin.core.registry import LspCheckApplicableCommand
from .plugin.core.registry import LspNextDiagnosticCommand
Expand Down Expand Up @@ -88,10 +90,18 @@
from .plugin.tooling import LspParseVscodePackageJson
from .plugin.tooling import LspTroubleshootServerCommand
from typing import Any
from typing import TYPE_CHECKING
import os
import sublime
import sublime_aio
import sublime_plugin

if TYPE_CHECKING:
import asyncio

# Uncomment to see all invocations that are marked @deprecated in the Console.
# warnings.simplefilter('always', DeprecationWarning)

__all__ = (
"DocumentSyncListener",
"Listener",
Expand Down Expand Up @@ -219,14 +229,14 @@ def show_warning() -> None:

def plugin_unloaded() -> None:
_unregister_all_plugins()
windows.disable()
run_coroutine_threadsafe(windows.disable())
unload_settings()


class Listener(sublime_plugin.EventListener):
class Listener(sublime_aio.EventListener):

def on_exit(self) -> None:
kill_all_subprocesses()
async def on_exit(self) -> None:
await kill_all_subprocesses()

def on_load_project_async(self, window: sublime.Window) -> None:
if manager := windows.lookup(window):
Expand Down Expand Up @@ -255,27 +265,27 @@ def on_pre_move(self, view: sublime.View) -> None:
sublime.set_timeout_async(listener.on_post_move_window_async, 1)
return

def on_load(self, view: sublime.View) -> None:
async def on_load(self, view: sublime.View) -> None:
file_name = view.file_name()
if not file_name:
return
for fn in g_opening_files:
if fn == file_name or os.path.samefile(fn, file_name):
# Remove it from the pending opening files, and resolve the promise.
g_opening_files.pop(fn)[1](view)
break
if future := await self._find_opening_file_future(file_name):
future.set_result(view)

def on_pre_close(self, view: sublime.View) -> None:
async def on_pre_close(self, view: sublime.View) -> None:
file_name = view.file_name()
if not file_name:
return
for fn in g_opening_files:
if fn == file_name or os.path.samefile(fn, file_name):
tup = g_opening_files.pop(fn, None) # noqa: B909
if tup:
# The view got closed before it finished loading. This can happen.
tup[1](None)
break
if future := await self._find_opening_file_future(file_name):
# The view got closed before it finished loading. This can happen.
future.set_result(None)

async def _find_opening_file_future(self, file_name: str) -> asyncio.Future[sublime.View | None] | None:
async with get_opening_files_lock():
for fn in g_opening_files:
if fn == file_name or os.path.samefile(fn, file_name): # noqa: ASYNC240
return g_opening_files.pop(fn, None)
return None

def on_post_window_command(self, window: sublime.Window, command_name: str, args: dict[str, Any] | None) -> None:
if command_name == "show_panel":
Expand Down
1 change: 1 addition & 0 deletions dependencies.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"bracex",
"mdpopups",
"orjson",
"sublime_aio",
"typing_extensions",
"wcmatch"
]
Expand Down
50 changes: 39 additions & 11 deletions plugin/api.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from __future__ import annotations

from ..protocol import ConfigurationItem
from ..protocol import DocumentUri
from ..protocol import ExecuteCommandParams
from ..protocol import LSPAny
from .core.constants import ST_STORAGE_PATH
from .core.logging import exception_log
from .core.protocol import Notification
from .core.protocol import Request
Comment on lines +3 to +10
Copy link
Copy Markdown
Member

@rchl rchl May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of those additions can be removed (debug unused, the rest duplicated)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW. There are still repeated imports in in here and in TYPE_CHECKING section.

Would be nice if ruff reported those.

from .core.protocol import Response
from .core.settings import client_configs
from .core.types import method2attr
Expand All @@ -14,6 +19,7 @@
from functools import wraps
from pathlib import Path
from typing import Any
from typing import Awaitable
from typing import Callable
from typing import Final
from typing import final
Expand Down Expand Up @@ -221,31 +227,33 @@ def decorator(func: Callable[[Any, P], None]) -> Callable[[Any, P], None]:

def request_handler(
method: str
) -> Callable[[Callable[[Any, P], Promise[R]]], Callable[[Any, P, int], Promise[Response[R]]]]:
) -> Callable[[Callable[[Any, P], Awaitable[R]]], Callable[[Any, P, int], Awaitable[Response[R]]]]:
"""
Decorator to mark a method as a handler for a specific LSP request.
Decorator to mark a coroutine method as a handler for a specific LSP request.

Usage:
```py
@request_handler('eslint/openDoc')
def on_open_doc(self, params: TextDocumentIdentifier) -> Promise[bool]:
async def on_open_doc(self, params: TextDocumentIdentifier) -> bool:
Comment thread
rchl marked this conversation as resolved.
...
```

The decorated method will be called with the request parameters whenever the specified
request is received from the language server. The method must return a Promise that resolves
to the response value. The framework will automatically send it back to the server.
The decorated coroutine method will be called with the request parameters whenever the specified
request is received from the language server. The coroutine method must return a response value.
The framework will automatically send it back to the server.

An older, but backwards-compatible way to define a request handler is by defining a function that returns a Promise.
While that works, the advice is to define a coroutine function.

:param method: The LSP request method name (e.g., 'eslint/openDoc').
:returns: A decorator that registers the function as a request handler.
:returns: A decorator that registers the coroutine function as a request handler.
"""

def decorator(func: Callable[[Any, P], Promise[R]]) -> Callable[[Any, P, int], Promise[Response[R]]]:
def decorator(func: Callable[[Any, P], Awaitable[R]]) -> Callable[[Any, P, int], Awaitable[Response[R]]]:

@wraps(func)
def wrapper(self: Any, params: P, request_id: int) -> Promise[Response[Any]]:
promise = func(self, params)
return promise.then(lambda result: Response(request_id, result))
async def wrapper(self: Any, params: P, request_id: int) -> Response[Any]:
return Response(request_id, await func(self, params))

setattr(wrapper, HANDLER_MARKER, method)
return wrapper
Expand Down Expand Up @@ -390,6 +398,9 @@ def plugin_unloaded() -> None:
Use this as your directory to install server files. Its path is `$DATA/Package Storage/<Package Name>`.
"""

use_asyncio: bool = False
"""Set to `true` to make LSP use `async def` variants."""

@classmethod
@final
def register(cls) -> None:
Expand Down Expand Up @@ -464,6 +475,19 @@ def on_pre_start_async(cls, context: OnPreStartContext) -> None:
"""
pass

@classmethod
async def on_pre_start(cls, context: OnPreStartContext) -> None:
"""
Async version of on_pre_start_async.

Attempt to use non-blocking functionality for downloading binaries and running subprocesses in order to not
block the asyncio thread.

:param context: The startup context. `context.configuration`, `context.variables` and
`context.working_directory` can be mutated to influence how the server is launched.
"""
pass

def __init__(self, weaksession: ref[Session]) -> None:
"""
Constructs a new instance.
Expand Down Expand Up @@ -491,6 +515,10 @@ def on_initialized_async(self) -> None:
"""
pass

async def on_initialized(self) -> None:
"""Async version of `on_initialize_async`."""
pass

def on_pre_send_request_async(self, request: ClientRequest, view: sublime.View | None) -> None:
"""
Notifies about a request that is about to be sent to the language server.
Expand Down
78 changes: 36 additions & 42 deletions plugin/code_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
from ..protocol import CodeActionParams
from ..protocol import Command
from ..protocol import Diagnostic
from ..protocol import LSPAny
from .core.aio import call_soon_threadsafe
from .core.aio import run_coroutine_threadsafe
from .core.logging import trace
from .core.promise import Promise
from .core.protocol import Error
from .core.protocol import Request
Expand All @@ -23,20 +27,21 @@
from functools import partial
from typing import Any
from typing import cast
from typing import Coroutine
from typing import final
from typing import List
from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
from typing_extensions import override
import asyncio
import sublime

if TYPE_CHECKING:
from .core.sessions import AbstractViewListener
from .core.sessions import SessionBufferProtocol
from collections.abc import Callable
from collections.abc import Generator
from collections.abc import Iterator
from typing_extensions import TypeGuard


Expand Down Expand Up @@ -65,9 +70,9 @@ def is_quickfix(action: Command | CodeAction) -> bool:


def filter_quickfix_actions(
only_with_diagnostics: bool, response: list[Command | CodeAction] | Error | None
only_with_diagnostics: bool, response: list[Command | CodeAction] | BaseException | None
) -> list[Command | CodeAction]:
if isinstance(response, Error) or not response:
if isinstance(response, BaseException) or not response:
return []
if only_with_diagnostics:
# If there are multiple diagnostics for the region, in the hover popup we can only use those code actions which
Expand Down Expand Up @@ -202,17 +207,20 @@ def on_response(
sb: SessionBufferProtocol, response: Error | list[CodeActionOrCommand] | None
) -> CodeActionsByConfigName:
actions = []
trace(sb=sb, response=response)
if response and not isinstance(response, Error):
# Filter actions returned from the session so that only matching kinds are collected.
# Since older servers don't support the "context.only" property, those will return all
# actions that need to be then manually filtered.
session_kinds = get_session_kinds(sb)
matching_kinds = get_matching_kinds(code_actions, session_kinds)
actions = [a for a in response if a.get('kind') in matching_kinds and not a.get('disabled')]
trace(session_kinds=session_kinds, matching_kinds=matching_kinds, actions=actions)
return (sb.session.config.name, actions)

for sb in listener.session_buffers_async('codeActionProvider'):
matching_kinds = get_matching_kinds(code_actions, get_session_kinds(sb))
trace(code_actions=code_actions, matching_kinds=matching_kinds)
for kind in matching_kinds:
listener.purge_changes_async()
# Pull for diagnostics to ensure that server computes them before receiving code action request.
Expand Down Expand Up @@ -281,35 +289,21 @@ def get_code_action_kinds(cls, view: sublime.View) -> dict[str, bool]:
}

@override
def run_async(self) -> None:
super().run_async()
view = self._task_runner.view
async def run(self) -> None:
await super().run()
trace()
view = self._text_command.view
code_action_kinds = self.get_code_action_kinds(view)
request_iterator = actions_manager.request_on_save_or_format_async(view, code_action_kinds)
self._process_next_request(request_iterator)

def _process_next_request(self, request_iterator: Iterator[Promise[CodeActionsByConfigName]]) -> None:
if self._cancelled:
return
if request := next(request_iterator, None):
request.then(lambda response: self._handle_response_async(response, request_iterator))
else:
self._on_complete()

def _handle_response_async(
self, response: CodeActionsByConfigName, request_iterator: Iterator[Promise[CodeActionsByConfigName]]
) -> None:
if self._cancelled:
return
view = self._task_runner.view
tasks: list[Promise[None]] = []
config_name, code_actions = response
session = self._task_runner.session_by_name(config_name, 'codeActionProvider')
if session and code_actions:
tasks.extend([
session.run_code_action_async(action, progress=False, view=view) for action in code_actions
])
Promise.all(tasks).then(lambda _: self._process_next_request(request_iterator))
tasks: list[Coroutine[None, None, LSPAny]] = []
for request in actions_manager.request_on_save_or_format_async(view, code_action_kinds):
config_name, code_actions = await request
trace(code_actions=code_actions)
if code_actions and (session := self._text_command.session_by_name(config_name, 'codeActionProvider')):
tasks.extend(
session.run_code_action(action, progress=False, view=self._text_command.view)
for action in code_actions
)
await asyncio.gather(*tasks)


@final
Expand Down Expand Up @@ -386,9 +380,9 @@ def run(
if code_actions_by_config:
self._handle_code_actions(code_actions_by_config, run_first=True)
return
self._run_async(only_kinds)
run_coroutine_threadsafe(self._run(only_kinds))

def _run_async(self, only_kinds: list[str | CodeActionKind] | None = None) -> None:
async def _run(self, only_kinds: list[str | CodeActionKind] | None = None) -> None:
view = self.view
region = first_selection_region(view)
if region is None:
Expand Down Expand Up @@ -427,13 +421,13 @@ def _handle_select(self, index: int, actions: list[tuple[ConfigName, CodeActionO
if index == -1:
return

def run_async() -> None:
async def run() -> None:
config_name, action = actions[index]
if session := self.session_by_name(config_name):
session.run_code_action_async(action, progress=True, view=self.view) \
.then(lambda response: self._handle_response_async(config_name, response))
response = await session.run_code_action(action, progress=True, view=self.view)
self._handle_response_async(config_name, response)

sublime.set_timeout_async(run_async)
run_coroutine_threadsafe(run())

def _handle_response_async(self, session_name: str, response: Any) -> None:
if isinstance(response, Error):
Expand Down Expand Up @@ -463,7 +457,7 @@ def is_enabled(self, index: int, event: dict | None = None) -> bool:
def is_visible(self, index: int, event: dict | None = None) -> bool:
if index == -1:
if self._has_session(event):
sublime.set_timeout_async(partial(self._request_menu_actions_async, event))
call_soon_threadsafe(partial(self._request_menu_actions_async, event))
return False
return index < len(self.actions_cache) and self._is_cache_valid(event)

Expand All @@ -488,14 +482,14 @@ def want_event(self) -> bool:
return True

def run(self, index: int, event: dict | None = None) -> None:
sublime.set_timeout_async(partial(self.run_async, index, event))
run_coroutine_threadsafe(self._run(index, event))

def run_async(self, index: int, event: dict | None) -> None:
async def _run(self, index: int, event: dict | None) -> None:
if self._is_cache_valid(event):
config_name, action = self.actions_cache[index]
if session := self.session_by_name(config_name):
session.run_code_action_async(action, progress=True, view=self.view) \
.then(lambda response: self._handle_response_async(config_name, response))
response = await session.run_code_action(action, progress=True, view=self.view)
self._handle_response_async(config_name, response)

def _handle_response_async(self, session_name: str, response: Any) -> None:
if isinstance(response, Error):
Expand Down
Loading
Loading