Skip to content

Use asyncio#2880

Draft
rwols wants to merge 106 commits into
mainfrom
feat/asyncio
Draft

Use asyncio#2880
rwols wants to merge 106 commits into
mainfrom
feat/asyncio

Conversation

@rwols
Copy link
Copy Markdown
Member

@rwols rwols commented Apr 22, 2026

This is for now a draft PR where I'm trying to introduce asyncio and replace callback-based code with async functions. I'll keep a todo list here for the things that I believe need to be done before marking this work as ready for review. You are free to comment, make suggestions, and dispute, during the draft of course. Lots of functionality does not work at all at the moment of opening this work as draft.

The main driver for doing this is to decrease the thread usage of this plugin from O(n) to O(1) threads, where n is the number of language servers running. The secondary driver is syntax sugar.

  • make WindowManager.start async
  • make WindowManager.start work
  • remove complicated listener enqueue/dequeue logic in WindowManager, replace by async lock
  • fix response_handlers dict not having the response handlers... sometimes
  • fix progress reporting
  • fix returning a request_id for completion logic
  • make DocumentSyncListener inherit from sublime_aio.ViewEventListener
  • post session initialized to listeners
  • make listener registration work correctly
  • make requests work
  • make document sync working
  • make lsp features work... mostly
  • test language server installation of plugins
  • handle plugin_loaded / plugin_unloaded
  • fix outstanding type errors
  • review usage of sublime.set_timeout_async at every call site
  • make @request_handler work with both async and -> Promise[...] variants
  • send didOpen before anything else
  • think about plugin code and where their functions run
  • fix unit/integration tests
  • fix tooling.py
  • check py38 runtime
Topic Old Way New Way
Running a short function on a thread that's not the main thread sublime.set_timeout_async LSP.plugin.core.aio.call_soon_threadsafe
Defining a function that's asynchronous def f() -> Promise[T]: ... async def f() -> T: ...
Chaining asynchronous functions Promise.then(lambda x: ...) x = await f()
Doing something when a server request is done session.send_request_async(R(), lambda x: ...) x = await session.request(R())
Doing something when a server request fails define an on_error callback use a try ... except ResponseException: block
Handling partial request results define an on_partial_result callback async for partial_result in session.stream(R()): (caveat: only works for list[...]-style responses)
Starting a coroutine from a regular function n/a LSP.plugin.core.aio.run_coroutine_threadsafe(f())
Awaiting old-style Promise objects Promise.then await promise
Waiting for all asynchronous operations to complete Promise.all asyncio.gather
Doing something later sublime.set_timeout_async(f, timeout_ms=1000) await asyncio.sleep(1)
Enforcing a critical section use threading.Lock, or write very complicated queueing logic use asyncio.Lock
Wrapping a new async function in a Promise n/a Promise.wrap_task
f calls g async g blocking g
async f async def f(): await g() async def f(): g()
blocking f, guaranteed called from asyncio thread use aio.TaskContainer.create_task(g()) def f(): g()
blocking f, any thread def f(): aio.run_coroutine_threadsafe(g()), or use aio.TaskContainer.create_task_threadsafe(g()) def f(): g()

The Plan

Make "most" code run on the sublime_aio thread

Most code is doing bookkeeping. This type of code used to run on the Sublime "async" thread. It should run on the asyncio loop thread.

Previously, the code attempted to make most code run on the ST async thread. We never really enforced this. We tried to make it clear that a function/method should be running on the ST async thread by suffixing it with _async.

If you have an async def coroutine function, then such a coroutine function is forced to run on the asyncio loop thread. So enforcement becomes automatic.

Keep _async suffixes, assume they run on the asyncio thread

When a method or function has the suffix _async in its name, we tried to ensure these functions run on the ST async thread. These can now be assumed to be running on the asyncio thread.

Make compute-intensive function run on the Sublime "async" thread

The only compute-intensive code we deal with are parsing and emitting JSON. Only the JSON parser/emitter should run on the ST async thread.

Bridging code for existing LSP-* plugins

We made sure that all AbstractPlugin and LspPlugin related (class)methods ran on the ST async thread. I want to now make sure all these (class)methods run on the sublime_aio thread with this pull request.

Certain methods may also be marked async for LspPlugin, most notably on_pre_start and perhaps on_initialize.

The Promise object can be awaited, so older AbstractPlugin/LspPlugin-related functionality returning promises from request handlers should work (although currently untested).

TODO: work this out futher (there's a Promise.__await__ defined, so at least we can await promises)

rwols added 13 commits April 13, 2026 23:50
Add a test that checks that "tcp server mode" works. Server meaning
that this plugin acts as the TCP server and the langserver connects as
TCP client.
Add a test that checks that "tcp server mode" works. Server meaning
that this plugin acts as the TCP server and the langserver connects as
TCP client.
Conflicts:
	tests/server.py
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 22, 2026

Deploy Preview for sublime-lsp ready!

Name Link
🔨 Latest commit 0aa553b
🔍 Latest deploy log https://app.netlify.com/projects/sublime-lsp/deploys/6a0f57a7aec2c50008025741
😎 Deploy Preview https://deploy-preview-2880--sublime-lsp.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread tests/server.py Outdated
@predragnikolic

This comment was marked as resolved.

@rwols
Copy link
Copy Markdown
Member Author

rwols commented Apr 22, 2026

It's okay if #2739 is merged before this. I'll take care of the merge conflicts.

Also write the rest of the requests in terms of Session.request
@rchl
Copy link
Copy Markdown
Member

rchl commented Apr 22, 2026

Would be useful to split it into smaller chunks, if possible. For example it would likely be possible to make start_async async by making it return a Promise and then later convert it to asyncio easily.

Lots of assumptions on my side but that's what I feel.

I was actually looking into that before as I wanted to move start_async into dedicated thread so that it doesn't black other plugins (kinda opposite goal of yours but also kinda similar as I guess with asyncio it will also run on dedicated thread). See #2863

It will be hard to review it properly with a big dump of code that refactors most of the code base.

Comment thread plugin/core/promise.py Outdated
Comment thread plugin/core/windows.py Outdated
Comment thread plugin/core/windows.py Outdated
rwols added 5 commits April 28, 2026 18:53
- Add sublime.set_timeout executor wrapper
- Make all request handlers `async`
- Define a CancellableInflightStreamingRequest class that enables `async for` syntax
- Start inheriting DocumentSyncListener from sublime_aio.ViewEventListener
  (This one doesn't work yet)

The state is fairly broken at this point.
rwols added 10 commits May 15, 2026 15:37
Takes a list of exceptions and prints all of them.

Also expand the `trace` function so it can print stack traces and
optionally some values.
THis exception can occur when "violently" opening and closing tabs.
…cancel_all_tasks

aclosing: is to be used in an `async with` context for safely closing
asynchronous generators (like the CancellableInflightStreamingRequest).

gather_and_flatten_exceptions: when using asyncio.gather with the
return_exceptions=True keyword argument, and when all coroutines in the
asyncio.gather already return lists of exceptions, then flatten the
lists and filter out exceptions that are not Exception (but do inherit
from BaseException).

TaskContainer.cancel_all_tasks: using the __del__ magic method for the
TaskContainer is unreliable as it may be destroyed from any thread
(and in fact, for things like SessionBuffer, these objects do get
created and destroyed in random threads that we don't even control.
Just put a trace(print_exceptions=True) in the __del__ of SessionBuffer).
So introduce an explicit TaskContainer.cancel_all_tasks async method
that cancels all tasks (and awaits their cancellation).
An asyncio.Lock class has the restriction that it should only be constructed
in the asyncio thread. This is a restriction up to python 3.10. From
python 3.10 onwards you can construct these objects from any thread.
…est, and proper shutdown mechanism

Fixes for python 3.8 runtime: can't construct asyncio.Lock on any other
thread than the asyncio thread, so just construct it just-in-time in the
WindowManager.

asyncio Locks are interesting in that you don't actually need to care
about the critical section just before the lock. Only at `await`
("suspension") points does the locking mechanism matter.

CancellableInflightStreamingRequest: uses an asyncio.Queue for properly
storing partial results if there's no awaiter yet.

Shutting things down: cancelling all tasks explicitly, and tweaks to
the Session automatically shutting down when there are no more views
attached to it.
Comment thread plugin/core/aio.py
async def cancel_all_tasks(self) -> list[Exception]:
return [x for x in await asyncio.gather(*self._tasks, return_exceptions=True) if isinstance(x, Exception)]

def create_task(self, coro: Coroutine[object, object, object], /, **kwargs: Any) -> asyncio.Task:
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.

Are you on purpose using / instead of (stricter) * marker?

In this case the upstream API also uses * so it would make sense to follow but I think that * generally is nicer due to being stricter.

Also instead of kwargs: Any I would manually just repeat the upstream arguments. In this case it's just a single name anyway.

Comment thread plugin/save_command.py
@override
def on_before_tasks(self) -> None:
sublime.set_timeout_async(self._trigger_on_pre_save_async)
call_soon_threadsafe(self._trigger_on_pre_save_async)
Copy link
Copy Markdown
Member

@rchl rchl May 16, 2026

Choose a reason for hiding this comment

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

(I'm back at naming again :))

How about we call this one call_soon_on_async_thread?

call_soon_threadsafe really doesn't tell me much about:

  • which thread it's gonna be called on
  • what is really thread safe about it

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.

And as for run_coroutine_threadsafe, how about run_on_asyncio_thread? The argument type should enforce that it's used only with coroutines so IMO that doesn't need to be specified. And threadsafe is kinda useless detail IMO, especially since there is no non-threadsafe variant exposed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

For context: I named these functions because they are so named in the asyncio module: AbstractEventLoop.call_soon_threadsafe and asyncio.run_coroutine_threadsafe.

rwols and others added 14 commits May 16, 2026 17:46
* I missed calling Session._handle_plugin_on_pre_send_response_async in session.on_payload
* Re-introduce a (deprecated) Session.execute_command_async
* Invoke response handler / notification handler right away
* Session.__getattr__ exists, so type checkers do not flag incorrect method calls.
  (found thanks to diagnostics tests).

* When you put a Coroutine object into a Promise.then, then... nothing happens.
  This caused on-save tasks to be broken (found thanks to code action tests).

* Fix a missing call to run_coroutine_threadsafe in completion.py causing the
  shift-selected behavior to be broken (found thanks to completion tests).

I'm having a hard time getting the FileWatcher tests working locally.
* Fix a bug in DocumentSyncListener.on_load
* Debugging code action tests
Comment thread plugin/core/sessions.py
Comment on lines 1661 to +1667
if plugin.on_open_uri_async(uri, callback):
return promise.then(lambda tup: self.open_scratch_buffer(*tup, flags, group)) \
.then(lambda view: self._on_sheet_opened(view.sheet(), uri, r))
title, content, syntax = await promise
view = await self.open_scratch_buffer(title, content, syntax, flags, group)
self._on_sheet_opened(view.sheet(), uri, r)
# resolve unused promise
resolve(('', '', ''))
return None
return False
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.

The if plugin... block should likely have its own return.

Also can you change the return type to sublime.View | False | None since we don't expect True?

Comment thread plugin/core/sessions.py Outdated
Comment on lines +1599 to +1603
if isinstance(self._plugin, LspPlugin):
scheme, _ = parse_uri(uri)
if handler := self._plugin.get_uri_handler(scheme):
return handler(uri, flags).then(lambda sheet: self._on_sheet_opened(sheet, uri, r))
sheet = await handler(uri, flags)
await loop.run_in_executor(executor_main, self._on_sheet_opened, sheet, uri, r)
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.

Most likely missing return here also

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I guess it also doesn’t need the run_in_executor, unless it does, in which case the other call site is wrong… but the current code also doesn’t make sure it runs on the main thread so I’ll remove this run_in_executor wrap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants