Skip to content

Use asyncio#2880

Draft
rwols wants to merge 24 commits intomainfrom
feat/asyncio
Draft

Use asyncio#2880
rwols wants to merge 24 commits intomainfrom
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 document sync working
  • make lsp features work
  • think about plugin code and where their functions run
Topic Old Way New Way
Running a short function on a thread that's not the main thread sublime.set_timeout_async sublime_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 sublime_aio.run_coroutine(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
Sending server requests (details) use a concurrent send queue use sublime_aio.run_coroutine

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.

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 ea34714
🔍 Latest deploy log https://app.netlify.com/projects/sublime-lsp/deploys/69f67744a38ae900085b6b8d
😎 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
self._new_session = None
sublime.set_timeout_async(self._dequeue_listener_async)
self._window.status_message(message)
async def start(self, config: ClientConfig, listener: AbstractViewListener) -> None:
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.

Not sure if relevant, and my understanding of asyncio is also poor at the moment, but not too long ago I added a lsp_check_applicable command that can be used by plugins to start/stop the session (or attach/detach) for a given view (used by LSP-copilot on changes to .copilotignore file):

LSP/plugin/core/registry.py

Lines 258 to 262 in 59c9d86

wm.start_async(config, self.view)
if wm._new_session:
wm._sessions.add(wm._new_session)
listener.on_session_initialized_async(wm._new_session)
wm._new_session = None

This uses start_async and also a few additional lines that were required to start a new session properly.

That code probably needs to be adapted to this rewrite as well.

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.

Note that this code will likely change (#2893).

Comment thread plugin/core/windows.py
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.
Comment thread plugin/core/protocol.py Outdated
Comment thread plugin/core/sessions.py
Comment on lines -1553 to +1646
self._on_sheet_opened(view.sheet(), uri, r).then(resolve)
sheet = view.sheet()
if sheet and (view := sheet.view()):
view.settings().set('lsp_uri', uri) # Preserve original URI given by the language server
if r:
center_selection(view, r)
return view
return None
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.

Probably wrong conflict resolution?

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.

Sorry, could you point out what’s wrong? I remember spending time on this exact conflict and thinking this was the right resolution. But I’ve been wrong many times in the past.

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.

It's not calling _on_sheet_opened anymore. The logic was moved there.

Comment thread plugin/core/sessions.py Outdated
Comment on lines +1868 to +1896
self.workspace_diagnostics_pending_responses[identifier] = inflight_request = self.request(
Request.workspaceDiagnostic(params),
partial_results=True
)
try:
async for partial_response in inflight_request:
for diagnostic_report in partial_response['items']:
uri = normalize_uri(diagnostic_report['uri'])
version = diagnostic_report['version']
# Skip if outdated
if isinstance(version, int) and (session_buffer := self.get_session_buffer_for_uri(uri)) and \
version < session_buffer.last_synced_version:
continue
self.diagnostics_result_ids[(uri, identifier)] = diagnostic_report.get('resultId')
if is_workspace_full_document_diagnostic_report(diagnostic_report):
self.handle_diagnostics(uri, identifier, version, diagnostic_report['items'])
await inflight_request
self.workspace_diagnostics_pending_responses[identifier] = None
except ResponseException as e:
if e.error['code'] == LSPErrorCodes.ServerCancelled:
data = e.error.get('data')
if is_diagnostic_server_cancellation_data(data) and data['retriggerRequest']:
# Retrigger the request after a short delay, but don't reset the pending response variable for this
# moment, to prevent new requests of this type in the meanwhile. The delay is used in order to prevent
# infinite cycles of cancel -> retrigger, in case the server is busy.
sublime.set_timeout_async(
lambda: self._do_workspace_diagnostics_async(identifier),
WORKSPACE_DIAGNOSTICS_RETRIGGER_DELAY
)
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.

Do we need to create such specific code to handle partial result?

The original logic seemed so much simpler, we just provide a handler for partial result and server either uses it or not.

With this new approach we need to create quite a bit different handler if partial result is possible and in practice it's possible in many more cases (that we currently don't handle).

Is it not possible to provide a callback for handling results (basically like before) but also await the request? Would awaited request block the thread or it would work fine to hang there for potentially a long time?

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.

Good questions. This code, handling diagnostics, is currently broken. I'm still working out the details for partial results.

The latest iteration on this is the CancellableInflightStreamingRequest class I wrote in core/sessions.py for partial results. It is returned from a new Session.stream request:

async for partial_result in session.stream(Request("foobar")):
    # do something with partial_result

The (broken) code that you highlighted here also does a final await inflight_request, but I thought that was not intuitive. For partial requests, only a single async for loop should be necessary.

The original logic seemed so much simpler, we just provide a handler for partial result and server either uses it or not.

I don't agree, respectfully. Having the logic split over various callback functions is a worse experience than having the logic be in a single function.

However, setting a on_partial_result will still be needed for server requests that do not stream their partial results in a list[R] fashion. The fact that the final partial result will be an empty response list allows the async for logic to detect when to end the loop. Moreover I plan to just keep the on_partial_result callback logic available.

Comment thread plugin/core/sessions.py Outdated
Comment thread plugin/core/transports.py
Comment on lines -499 to +476
message = self._reader.readline().decode("utf-8", "replace")
if not message:
continue
raw = await self._reader.readline()
if not raw:
break
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.

Why break instead of continue?

This code is pretty well battle-tested so changing the logic can be quite risky.

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 tend to agree with you here. And I’ll try to revert this. But, my experience was: using continue here caused 100% CPU usage, suggesting an infinite loop. I’ll reconsider this.

Comment thread plugin/core/transports.py
Comment on lines +276 to +287
async def parse_headers(reader: asyncio.StreamReader) -> dict[str, str] | None:
headers: dict[str, str] = {}
while True:
line = await reader.readline()
if not line:
return None
line = line.decode("ascii").strip()
if not line:
break
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
return headers
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.

Can we not use http.client.parse_headers still? I feel that this implementation might be a bit naive compared to the original one.

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.

Unfortunately no. There is no equivalent in asyncio. But JSON-RPC is simpler than HTTP anyway. There is only the Content-Length header from what I know. I do agree this manual header parser is less ideal than something from the stdlib.

Comment thread plugin/core/windows.py Outdated
Comment thread plugin/api.py
Comment on lines +3 to +11
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.logging import debug
from .core.protocol import Notification
from .core.protocol import Request
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)

@rchl
Copy link
Copy Markdown
Member

rchl commented May 3, 2026

An idea: keep original method names with _async suffix while working on the task and only rename in a dedicated PR after asyncio is actually merged.

Why? Because renaming introduces a significant amount of changes which means:

  • higher chance of conflicts with ongoing changes and more work resolving conflicts
  • a lot more (distracting) changes to go through when reviewing

@rwols
Copy link
Copy Markdown
Member Author

rwols commented May 3, 2026

Agreed, removing async suffices (suffixes?) was a mistake, I’ll revert that.

@rwols rwols force-pushed the feat/asyncio branch 2 times, most recently from d716ebb to 2649a6f Compare May 4, 2026 17:14
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