Skip to content

Use asyncio (2) - #3021

Open
rwols wants to merge 263 commits into
mainfrom
feat/asyncio
Open

Use asyncio (2)#3021
rwols wants to merge 263 commits into
mainfrom
feat/asyncio

Conversation

@rwols

@rwols rwols commented Sep 4, 2026

Copy link
Copy Markdown
Member

This PR switches the codebase to using async def functions and asyncio. The loop provider is sublime_aio.

close #2863.

should be merged (and released) at the same time as:

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.

Why is this PR so large? Please read: What color is your function?

Continuation of: #2880

rwols added 30 commits April 22, 2026 20:17
Also write the rest of the requests in terms of Session.request
- 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.
- Typo fixes: various typos fixed

- Session starting logic: only partially. Sessions attach to listeners now,
  but, only one session starts while multiple should start. I think the
  solution is now to simply start all the `WindowManager.start` coroutines
  at the same time. They'll wait on each other via the `WindowManager._start_lock`.

- Fix folding ranges using `send_request_async` while it should be using
  `send_request` (because it calls that from the main thread).

- Requests seem to be generally working *provided pull diagnostics are not used*.
  Testing with clangd works, testing with pyright shows requests not working
  and something fundamental being stuck somewhere.

Broken:

- didOpen/didClose is sent two times

- Workspace/pull diagnostics are broken

- There's various `sublime.set_timeout_async` calls throughout the codebase,
  but we're now at the point where that's "wrong". I hope to find-and-replace
  these invocations with `sublime_aio.call_soon_threadsafe`.
@netlify

netlify Bot commented Sep 4, 2026

Copy link
Copy Markdown

Deploy Preview for sublime-lsp ready!

Name Link
🔨 Latest commit 13db423
🔍 Latest deploy log https://app.netlify.com/projects/sublime-lsp/deploys/6aa1b5571829fa00084a5ac1
😎 Deploy Preview https://deploy-preview-3021--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.

@rwols
rwols requested a review from rchl September 4, 2026 18:20
Comment thread plugin/core/sessions.py Outdated
Comment on lines +1562 to +1566
if task := self.create_task_threadsafe(
self.run_command(command, progress=progress, view=view, is_refactoring=is_refactoring)
):
return Promise.wrap_task(task)
raise RuntimeError("unable to schedule 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.

self.create_task_threadsafe doesn't return anything so this will always throw.

Comment thread plugin/core/sessions.py
self._plugin = self._plugin_class(weakref.ref(self))
self.transport = transport
self.working_directory = working_directory
self._variables = variables

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.

This line was dropped but there is still code that references self._variables which now will always be empty.

Comment thread plugin/core/sessions.py Outdated
return
self.workspace_diagnostics_pending_responses[identifier] = None
return
self._on_workspace_diagnostics_async(identifier, response, reset_pending_response=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.

All callers now call _on_workspace_diagnostics_async with reset_pending_response=False. That doesn't seem right.

Comment thread plugin/core/sessions.py Outdated
request_id = int(token[len(_WORK_DONE_PROGRESS_PREFIX):])
request = self._response_handlers[request_id][0]
self._invoke_views(request, "on_request_progress", request_id, params)
lambda: self._invoke_views(request, "on_request_progress", request_id, params)

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.

This syntax doesn't quite make sense - lambda created and discarded immediately?

Comment thread plugin/core/sessions.py Outdated
if os.path.isfile(new_path): # noqa: ASYNC240
if options.get('overwrite') and os.path.isfile(old_path): # noqa: ASYNC240
await delete_file(new_path)
await _continue(rename_file(old_path, new_path))

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.

Missing return?

Comment thread plugin/core/sessions.py
Comment on lines +1890 to +1894
while os.path.exists(path) and attempts < self._FILE_DELETED_MAX_CHECK_ATTEMPTS: # noqa: ASYNC240
await asyncio.sleep(0.1)
attempts += 1
if attempts >= self._FILE_DELETED_MAX_CHECK_ATTEMPTS:
raise asyncio.TimeoutError(f"Timeout waiting for deletion of {path}")

@rchl rchl Sep 6, 2026

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.

There should be a final check after the last sleep. Otherwise that last sleep is pointless.

Comment thread plugin/core/sessions.py
Comment on lines +2741 to +2743
with self._threading_condition:
run_on_asyncio_thread(set_request_id)
self._threading_condition.wait_for(lambda: request_id is not 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.

This will deadlock when request errors?

Comment thread plugin/core/sessions.py Outdated
return cast('ApplyWorkspaceEditResult', {}), cast('WorkspaceEditSummary', {})
return x

if task := self.create_task(self.apply_workspace_edit(edit, label=label, is_refactoring=is_refactoring)):

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.

Should this use a threadsafe variant to create task?

Comment thread plugin/core/sessions.py Outdated
Comment thread plugin/core/sessions.py
Comment on lines -880 to +905
def purge_changes_async(self) -> None:
def purge_changes(self) -> asyncio.Future[list[BaseException | None]]:
raise NotImplementedError

@abstractmethod
def trigger_on_pre_save_async(self) -> None:
def trigger_on_pre_save(self) -> asyncio.Future[list[BaseException | None]]:

@rchl rchl Sep 7, 2026

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.

Is there specific reason for annotating the return value with asyncio.Future vs. just using async keyword on the method?

Some methods here do it one way and some do another.

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.

The reason for returning asyncio.Future is that this function will unconditionally do that.

In a coroutine, say

async def trigger_on_pre_save(self) -> list[BaseException | None]:
    ...

you can conditionally return early, or conditionally await something and suspend. You need the function to be a coroutine function in that case.

But unconditionally, always, returning something awaitable, such a function does not have to be a coroutine function but can be a "simpler" regular function that returns the Future.

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 why do that? What is the benefit of trigger_on_pre_save implementation returning a Future rather than awaiting it itself?

Comment thread plugin/core/sessions.py Outdated
Comment on lines +1015 to +1016
_id: int | None
_weaksession: weakref.ref[Session]

@rchl rchl Sep 7, 2026

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 declare private properties this way? I would understand public, to act as a documentation, but for private this seems unnecessary. It would be better to let the type be inferred from the assignment in __init__ or assign explicit type there (can matter if class is overridden).

Comment thread plugin/core/sessions.py Outdated
) -> None:
transport: TransportWrapper
) -> InitializeResult | Error:
loop = asyncio.get_running_loop()

@rchl rchl Sep 7, 2026

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.

Is there a particular reason why loop variable is assigned on the first line if it's only needed in the last line?

Comment thread plugin/core/sessions.py Outdated
Comment thread plugin/core/sessions.py
Comment on lines -1391 to +1511
task: PackagedTask[R | Error | None] = Promise.packaged_task()
task: PackagedTask[LSPAny | Error | None] = Promise.packaged_task()
promise, resolve = task
if self._plugin.on_pre_server_command(command, lambda: resolve(None)):
return promise
resolve(None)
return cast("LSPAny", await promise)

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.

There was a resolve(None) here before. I think it should still be here. Not sure if not having it would prevent GC but even if not, better to clean after ourselves.

Comment thread plugin/core/sessions.py
Comment on lines +1592 to +1595
code_action_or_error = await self._maybe_resolve_code_action(code_action, view)
if isinstance(code_action_or_error, Error):
return code_action_or_error
return await self._apply_code_action(code_action_or_error, view)

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.

_apply_code_action does handle Error too. If you've made decision here to handle error at call-site then we can now remove Error handling from _apply_code_action.

Comment thread plugin/core/sessions.py
Comment on lines +1616 to 1628
return await self._open_file_uri(uri, r, flags, group)

# Try to find a pre-existing session-buffer
if sb := self.get_session_buffer_for_uri_async(uri):
view = sb.get_view_in_group(group)
self.window.focus_view(view)
if r:
center_selection(view, r)
return Promise.resolve(view)
return view
if scheme == 'res':
return self._open_res_uri_async(uri, r, group)
return await self._open_res_uri(uri, r, group)

if scheme == 'untitled': # VSCode specific URI scheme for unsaved buffers

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 remove empty lines?

Comment thread plugin/core/sessions.py Outdated
return
if isinstance(code_action, Error):
# TODO: our promise must be able to handle exceptions (or, wait until we can use coroutines)
# TODO: do something with the error?

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.

don't see what else than printing an error we can do

Comment thread plugin/core/sessions.py Outdated
x: tuple[ApplyWorkspaceEditResult, WorkspaceEditSummary] | BaseException,
) -> tuple[ApplyWorkspaceEditResult, WorkspaceEditSummary]:
if isinstance(x, BaseException):
return cast('ApplyWorkspaceEditResult', {}), cast('WorkspaceEditSummary', {})

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.

Forcing types could lead to crashes. This might be better:

Suggested change
return cast('ApplyWorkspaceEditResult', {}), cast('WorkspaceEditSummary', {})
return {"applied": False}, {"created_files": 0, "edited_files": 0, "total_changes": 0}

Comment thread plugin/core/sessions.py
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.

Server installation can block other plugins

2 participants