Repository: neovim/neovim
Pull request #33624: fix(lsp): detect if Client:request resolved synchronously
Author: login: PolyMeilex
Labels: name: lsp
Created: 2025-04-25T03:38:19Z | Merged: 2025-04-26T14:08:03Z

Problem:

In cases when the (in process) LSP server responds to the request immediately and calls `notify_reply_callback` the request will still be marked as pending, because the code assumes that the response will occur asynchronously, as a result the request will be pending forever, because it was already set as completed before we even set it as pending.

A workaround is to wrap `notify_replay_callback` in `vim.shedule` ([like so](https://github.com/neovim/neovim/pull/24338#issuecomment-2809568617)] but that seems counterintuitive.

Solution:

Handle this case in nvim lsp client itself. 


Diff:
diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua
index d8d46e425c1169..d2f9395b850263 100644
--- a/runtime/lua/vim/lsp/client.lua
+++ b/runtime/lua/vim/lsp/client.lua
@@ -678,6 +678,12 @@ function Client:request(method, params, handler, bufnr)
   bufnr = vim._resolve_bufnr(bufnr)
   local version = lsp.util.buf_versions[bufnr]
   log.debug(self._log_prefix, 'client.request', self.id, method, params, handler, bufnr)
+
+  -- Detect if request resolved synchronously (only possible with in-process servers).
+  local already_responded = false
+  local request_registered = false
+
+  -- NOTE: rpc.request might call an in-process (Lua) server, thus may be synchronous.
   local success, request_id = self.rpc.request(method, params, function(err, result)
     handler(err, result, {
       method = method,
@@ -688,11 +694,15 @@ function Client:request(method, params, handler, bufnr)
     })
   end, function(request_id)
     -- Called when the server sends a response to the request (including cancelled acknowledgment).
-    self:_process_request(request_id, 'complete')
+    if request_registered then
+      self:_process_request(request_id, 'complete')
+    end
+    already_responded = true
   end)
 
-  if success and request_id then
+  if success and request_id and not already_responded then
     self:_process_request(request_id, 'pending', bufnr, method)
+    request_registered = true
   end
 
   return success, request_id
diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua
index 78fcda610edebe..d2d50b42ecf66e 100644
--- a/test/functional/plugin/lsp_spec.lua
+++ b/test/functional/plugin/lsp_spec.lua
@@ -1252,6 +1252,67 @@ describe('LSP', function()
       }
     end)
 
+    it('request should not be pending for sync responses (in-process LS)', function()
+      clear()
+
+      --- @type boolean
+      local pending_request = exec_lua(function()
+        local function server(dispatchers)
+          local closing = false
+          local srv = {}
+          local request_id = 0
+
+          function srv.request(method, _params, callback, notify_reply_callback)
+            if method == 'textDocument/formatting' then
+              callback(nil, {})
+            elseif method == 'initialize' then
+              callback(nil, {
+                capabilities = {
+                  textDocument = {
+                    formatting = true,
+                  },
+                },
+              })
+            elseif method == 'shutdown' then
+              callback(nil, nil)
+            end
+            request_id = request_id + 1
+            if notify_reply_callback then
+              notify_reply_callback(request_id)
+            end
+            return true, request_id
+          end
+
+          function srv.notify(method)
+            if method == 'exit' then
+              dispatchers.on_exit(0, 15)
+            end
+          end
+          function srv.is_closing()
+            return closing
+          end
+          function srv.terminate()
+            closing = true
+          end
+
+          return srv
+        end
+
+        local client_id = assert(vim.lsp.start({ cmd = server }))
+        local client = assert(vim.lsp.get_client_by_id(client_id))
+
+        local ok, request_id = client:request('textDocument/formatting', {})
+        assert(ok)
+
+        local has_pending = client.requests[request_id] ~= nil
+        vim.lsp.stop_client(client_id)
+
+        return has_pending
+      end)
+
+      eq(false, pending_request, 'expected no pending requests')
+    end)
+
     it('should trigger LspRequest autocmd when requests table changes', function()
       local expected_handlers = {
         { NIL, {}, { method = 'finish', client_id = 1 } },


Reviews:
user: login: lewis6991
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T08:01:19Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T13:13:08Z
user: login: PolyMeilex
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T16:31:56Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T16:39:56Z
user: login: PolyMeilex
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T17:09:38Z
user: login: lewis6991
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T17:24:33Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T18:10:32Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T18:11:47Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T18:13:20Z
user: login: justinmk
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T18:13:35Z
user: login: justinmk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T19:16:06Z
user: login: PolyMeilex
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T19:21:53Z

Review comments:
diff_hunk: @@ -688,11 +693,15 @@ function Client:request(method, params, handler, bufnr)
     })
   end, function(request_id)
     -- Called when the server sends a response to the request (including cancelled acknowledgment).
-    self:_process_request(request_id, 'complete')
+    if request_registered then
+      self:_process_request(request_id, 'complete')
+    end
+    already_responded = true
   end)
 
-  if success and request_id then
+  if success and request_id and not already_responded then
path: runtime/lua/vim/lsp/client.lua
user: login: justinmk
body: The callback which sets `already_responded=true`, is called with `schedule_wrap(notify_reply_callback)`. 

How could `already_responded` ever be true here? Is there a coroutine yield somewhere?
created_at: 2025-04-25T13:13:08Z
author_association: MEMBER
diff_hunk: @@ -688,11 +693,15 @@ function Client:request(method, params, handler, bufnr)
     })
   end, function(request_id)
     -- Called when the server sends a response to the request (including cancelled acknowledgment).
-    self:_process_request(request_id, 'complete')
+    if request_registered then
+      self:_process_request(request_id, 'complete')
+    end
+    already_responded = true
   end)
 
-  if success and request_id then
+  if success and request_id and not already_responded then
path: runtime/lua/vim/lsp/client.lua
user: login: PolyMeilex
body: I assume you are referring to rpc client code right? https://github.com/neovim/neovim/blob/06613989a67e03044941a608536a775af1521c61/runtime/lua/vim/lsp/rpc.lua#L277

In-process servers don't use this code path right?
https://github.com/neovim/neovim/blob/06613989a67e03044941a608536a775af1521c61/runtime/lua/vim/lsp/client.lua#L463-L466

Technically, we could require in-process servers to wrap reply callbacks in `vim.schedule` like rpc does, and that would work eg. user's code would look like this:
```lua
  function server.request(method, params, callback, notify_reply_callback)
    -- ... request handlers go here

    if notify_reply_callback then 
      vim.schedule(function() notify_reply_callback(request_id) end)
    end

    return true, request_id
  end
```
But that seems like leaking implementation quirks to API users, I would never know that I have to do that schedule without reading nvim's source code.

(We could also introduce a tiny shim around in-process servers that wraps `notify_reply_callback` like rpc does, but just handling the sync case seemed easy enough so I did not try that yet)
created_at: 2025-04-25T16:31:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -688,11 +693,15 @@ function Client:request(method, params, handler, bufnr)
     })
   end, function(request_id)
     -- Called when the server sends a response to the request (including cancelled acknowledgment).
-    self:_process_request(request_id, 'complete')
+    if request_registered then
+      self:_process_request(request_id, 'complete')
+    end
+    already_responded = true
   end)
 
-  if success and request_id then
+  if success and request_id and not already_responded then
path: runtime/lua/vim/lsp/client.lua
user: login: justinmk
body: > Technically, we could require in-process servers to wrap reply callbacks in `vim.schedule` like rpc does,

No, I'm glad you're fixing this internally. Just trying to understand it.



> In-process servers don't use this code path right?

Ok, I missed that. Should there be a 1-line comment somewhere giving a hint about that?

Also a 1-line comment on these flags that explains they are for in-process server?
created_at: 2025-04-25T16:39:56Z
author_association: MEMBER
diff_hunk: @@ -688,11 +693,15 @@ function Client:request(method, params, handler, bufnr)
     })
   end, function(request_id)
     -- Called when the server sends a response to the request (including cancelled acknowledgment).
-    self:_process_request(request_id, 'complete')
+    if request_registered then
+      self:_process_request(request_id, 'complete')
+    end
+    already_responded = true
   end)
 
-  if success and request_id then
+  if success and request_id and not already_responded then
path: runtime/lua/vim/lsp/client.lua
user: login: PolyMeilex
body: Ok, I think it's addressed now with
`Sync responses are only possible with in-process servers`
and 
`NOTE: rpc.request might be an in-process server lua function call rather than rpc call`
created_at: 2025-04-25T17:09:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -688,11 +693,15 @@ function Client:request(method, params, handler, bufnr)
     })
   end, function(request_id)
     -- Called when the server sends a response to the request (including cancelled acknowledgment).
-    self:_process_request(request_id, 'complete')
+    if request_registered then
+      self:_process_request(request_id, 'complete')
+    end
+    already_responded = true
   end)
 
-  if success and request_id then
+  if success and request_id and not already_responded then
path: runtime/lua/vim/lsp/client.lua
user: login: lewis6991
body: I always thought that the `vim.schedule` wrapper was a bit fishy, I bet this was the reason for it. Maybe we can remove that now?
created_at: 2025-04-25T17:24:33Z
author_association: MEMBER
diff_hunk: @@ -678,6 +678,13 @@ function Client:request(method, params, handler, bufnr)
   bufnr = vim._resolve_bufnr(bufnr)
   local version = lsp.util.buf_versions[bufnr]
   log.debug(self._log_prefix, 'client.request', self.id, method, params, handler, bufnr)
+
+  -- Used to detect if request resolved synchronously
+  -- Sync responses are only possible with in-process servers
path: runtime/lua/vim/lsp/client.lua
user: login: justinmk
body: ```suggestion
  -- Detect if request resolved synchronously (only possible with in-process servers).
```
created_at: 2025-04-25T18:10:31Z
author_association: MEMBER
diff_hunk: @@ -678,6 +678,13 @@ function Client:request(method, params, handler, bufnr)
   bufnr = vim._resolve_bufnr(bufnr)
   local version = lsp.util.buf_versions[bufnr]
   log.debug(self._log_prefix, 'client.request', self.id, method, params, handler, bufnr)
+
+  -- Used to detect if request resolved synchronously
+  -- Sync responses are only possible with in-process servers
+  local already_responded = false
+  local request_registered = false
+
+  -- NOTE: rpc.request might be an in-process server lua function call rather than rpc call
path: runtime/lua/vim/lsp/client.lua
user: login: justinmk
body: ```suggestion
  -- NOTE: rpc.request might call an in-process (Lua) server, thus may be synchronous.
```
created_at: 2025-04-25T18:11:47Z
author_association: MEMBER
diff_hunk: @@ -1252,6 +1252,66 @@ describe('LSP', function()
       }
     end)
 
+    it('request should not be pending for sync responses from the language server', function()
path: test/functional/plugin/lsp_spec.lua
user: login: justinmk
body: ```suggestion
    it('request should not be pending for sync responses (in-process LS)', function()
```
created_at: 2025-04-25T18:13:20Z
author_association: MEMBER
diff_hunk: @@ -1252,6 +1252,66 @@ describe('LSP', function()
       }
     end)
 
+    it('request should not be pending for sync responses (in-process LS)', function()
+      clear()
+
+      --- @type boolean
+      local pending_request = exec_lua(function()
+        local function server(dispatchers)
+          local closing = false
+          local srv = {}
+          local request_id = 0
+
+          function srv.request(method, _params, callback, notify_reply_callback)
+            if method == 'textDocument/formatting' then
+              callback(nil, {})
+            elseif method == 'initialize' then
+              callback(nil, {
+                capabilities = {
+                  textDocument = {
+                    formatting = true,
+                  },
+                },
+              })
+            elseif method == 'shutdown' then
+              callback(nil, nil)
+            end
+            request_id = request_id + 1
+            if notify_reply_callback then
+              notify_reply_callback(request_id)
+            end
+            return true, request_id
+          end
+
+          function srv.notify(method)
+            if method == 'exit' then
+              dispatchers.on_exit(0, 15)
+            end
+          end
+          function srv.is_closing()
+            return closing
+          end
+          function srv.terminate()
+            closing = true
+          end
+
+          return srv
+        end
+
+        local client_id = assert(vim.lsp.start({ cmd = server }))
+        local client = assert(vim.lsp.get_client_by_id(client_id))
+
+        local ok, request_id = client:request('textDocument/formatting', {})
+        assert(ok)
+
+        vim.lsp.stop_client(client_id)
+
+        return client.requests[request_id] ~= nil
path: test/functional/plugin/lsp_spec.lua
user: login: justinmk
body: is it possible that `stop_client()` cleans up the pending requests? inspecting the code suggests no, currently. but would be better to not assume that.
```suggestion
        local has_pending = client.requests[request_id] ~= nil
        vim.lsp.stop_client(client_id)

        return has_pending
```
created_at: 2025-04-25T19:16:06Z
author_association: MEMBER
diff_hunk: @@ -1252,6 +1252,66 @@ describe('LSP', function()
       }
     end)
 
+    it('request should not be pending for sync responses (in-process LS)', function()
+      clear()
+
+      --- @type boolean
+      local pending_request = exec_lua(function()
+        local function server(dispatchers)
+          local closing = false
+          local srv = {}
+          local request_id = 0
+
+          function srv.request(method, _params, callback, notify_reply_callback)
+            if method == 'textDocument/formatting' then
+              callback(nil, {})
+            elseif method == 'initialize' then
+              callback(nil, {
+                capabilities = {
+                  textDocument = {
+                    formatting = true,
+                  },
+                },
+              })
+            elseif method == 'shutdown' then
+              callback(nil, nil)
+            end
+            request_id = request_id + 1
+            if notify_reply_callback then
+              notify_reply_callback(request_id)
+            end
+            return true, request_id
+          end
+
+          function srv.notify(method)
+            if method == 'exit' then
+              dispatchers.on_exit(0, 15)
+            end
+          end
+          function srv.is_closing()
+            return closing
+          end
+          function srv.terminate()
+            closing = true
+          end
+
+          return srv
+        end
+
+        local client_id = assert(vim.lsp.start({ cmd = server }))
+        local client = assert(vim.lsp.get_client_by_id(client_id))
+
+        local ok, request_id = client:request('textDocument/formatting', {})
+        assert(ok)
+
+        vim.lsp.stop_client(client_id)
+
+        return client.requests[request_id] ~= nil
path: test/functional/plugin/lsp_spec.lua
user: login: PolyMeilex
body: Yeah, indeed. It wouldn't be that surprising if it did behave that way (in the future).
created_at: 2025-04-25T19:21:53Z
author_association: CONTRIBUTOR

PR comments:
user: login: lewis6991
created_at: 2025-04-25T08:01:54Z
body: @justinmk I wonder if this will resolve the false errors we see in the logs
author_association: MEMBER
user: login: justinmk
created_at: 2025-04-25T13:01:08Z
body: ref:

- https://github.com/neovim/neovim/issues/32277#issuecomment-2629289430
- https://github.com/neovim/neovim/pull/32294
author_association: MEMBER
user: login: neovim-backports[bot]
created_at: 2025-04-26T15:27:59Z
body: Successfully created backport PR for `release-0.11`:
- #33652
author_association: NONE