fix(lsp): stop uninitialized clients #42640

Problem:
Various stop paths skip clients still waiting for an "initialize" response.
- Quitting Nvim leaves their servers running/dangling.
- `vim.lsp.enable(name, false)` and `:lsp stop|restart|disable` don't
  stop them.
- Extra `stop()` sends extra "shutdown" (instead of force-stopping as
  documented).
- Restart scheduled before exit *starts a server during exit*.
- A late "initialize" response initializes a client that is already
  being force-stopped...
- Force-stopping warns "Client X quit with exit code N".

Solution:
- Include `_uninitialized` clients in all stop paths.
- Extra `stop()` force-stops. The exit handler skips clients that are
  already stopping, so `exit_timeout=false` is still respected.
- Skip a scheduled restart during Nvim exit.
- Ignore the "initialize" response of a stopping client.
- Don't warn about the exit status of a stopped server.
This commit is contained in:
Justin M. Keyes authored and GitHub committed 2026-11-01 12:15:34 -05:00
1 parent 8876e363ae
commit 48dcaab903
6 files changed
+143 -20

No files matched your search

+7 -3
View File
@@ -1946,7 +1946,8 @@ vim.lsp.ClientConfig *vim.lsp.ClientConfig*
the "shutdown" request. See |Client:stop()|.
Note: when Nvim itself is exiting,
• `false`: Nvim will not force-stop LSP
server(s).
server(s), except uninitialized ones (see
|Client:stop()|).
• `true`: Nvim will force-stop LSP server(s)
that did not comply with the "shutdown"
request.
@@ -1988,7 +1989,9 @@ vim.lsp.ClientConfig *vim.lsp.ClientConfig*
`vim.lsp.rpc.client_errors[code]` to get
human-friendly name.
• {on_exit}? (`elem_or_list<fun(code: integer, signal: integer, client_id: integer)>`)
Callback invoked on client exit.
Callback invoked on client exit. On Nvim exit,
it runs only if the server exits within
`exit_timeout` milliseconds.
• code: exit code of the process
• signal: number describing the signal used to
terminate (if any)
@@ -2126,7 +2129,8 @@ Client:stop({force}) *Client:stop()*
milliseconds (unless `exit_timeout=false`). Calling stop() on a client
that was previously requested to shutdown, will escalate to force-stop
immediately, regardless of `force` (or `self.exit_timeout` if
`force=nil`).
`force=nil`). A server that has not answered "initialize" cannot receive
"shutdown", so it is force-stopped immediately.
Note: Forcing shutdown while a server is busy writing out project or index
files can lead to file corruption.
+5 -4
View File
@@ -28,7 +28,7 @@ end
--- @return string[]
local function get_client_names()
return vim
.iter(vim.lsp.get_clients())
.iter(vim.lsp.get_clients({ _uninitialized = true }))
:map(function(client)
return client.name
end)
@@ -97,7 +97,7 @@ local function ex_lsp_disable(config_names)
-- Default to disabling all clients attached to the current buffer.
if #config_names == 0 then
config_names = vim
.iter(vim.lsp.get_clients { bufnr = api.nvim_get_current_buf() })
.iter(vim.lsp.get_clients { bufnr = api.nvim_get_current_buf(), _uninitialized = true })
:map(function(client)
return client.name
end)
@@ -119,7 +119,8 @@ end
local function get_clients_from_names(client_names)
-- Default to all active clients attached to the current buffer.
if #client_names == 0 then
local clients = vim.lsp.get_clients { bufnr = api.nvim_get_current_buf() }
local clients =
vim.lsp.get_clients { bufnr = api.nvim_get_current_buf(), _uninitialized = true }
if #clients == 0 then
util.echo_err(N_('E5805: No clients attached to current buffer'))
end
@@ -128,7 +129,7 @@ local function get_clients_from_names(client_names)
return vim
.iter(client_names)
:map(function(name)
local clients = vim.lsp.get_clients { name = name }
local clients = vim.lsp.get_clients { name = name, _uninitialized = true }
if #clients == 0 then
util.echo_err(N_('E5806: No active clients matching name: %s'):format(name))
end
+5 -3
View File
@@ -665,7 +665,7 @@ function lsp.enable(name, enable)
end
else
for _, nm in ipairs(names) do
for _, client in ipairs(lsp.get_clients({ name = nm })) do
for _, client in ipairs(lsp.get_clients({ name = nm, _uninitialized = true })) do
client:stop(client.exit_timeout)
end
end
@@ -1176,13 +1176,15 @@ end
local min_warn_exit_timeout = 100
nvim_on('VimLeavePre', nil, { desc = 'vim.lsp: exit handler' }, function()
local active_clients = lsp.get_clients()
local active_clients = lsp.get_clients({ _uninitialized = true })
log.info('exit_handler', active_clients)
local max_timeout = 0
for _, client in pairs(active_clients) do
max_timeout = math.max(max_timeout, vim._tointeger(client.exit_timeout) or 0)
client:stop(client.exit_timeout)
if not client:is_stopped() then -- If client is already "stopping", stop() would force-stop.
client:stop(client.exit_timeout)
end
end
local exit_warning_timer = max_timeout > min_warn_exit_timeout
+17 -9
View File
@@ -99,7 +99,7 @@ end
---
--- Decides if/when to force-stop the server after sending the "shutdown" request. See |Client:stop()|.
--- Note: when Nvim itself is exiting,
--- - `false`: Nvim will not force-stop LSP server(s).
--- - `false`: Nvim will not force-stop LSP server(s), except uninitialized ones (see |Client:stop()|).
--- - `true`: Nvim will force-stop LSP server(s) that did not comply with the "shutdown" request.
--- - `number`: Nvim will wait up to `exit_timeout` milliseconds before performing force-stop.
--- (default: `false`)
@@ -134,7 +134,8 @@ end
--- for possible errors. Use `vim.lsp.rpc.client_errors[code]` to get human-friendly name.
--- @field on_error? fun(code: integer, err: string)
---
--- Callback invoked on client exit.
--- Callback invoked on client exit. On Nvim exit, it runs only if the server exits within
--- `exit_timeout` milliseconds.
--- - code: exit code of the process
--- - signal: number describing the signal used to terminate (if any)
--- - client_id: client handle
@@ -251,10 +252,6 @@ end
--- Timer for stop() with timeout.
--- @field private _shutdown_timer uv.uv_timer_t?
---
--- Track this so that we can escalate automatically if we've already tried a
--- graceful shutdown
--- @field private _graceful_shutdown_failed true?
---
--- The initial trace setting. If omitted trace is disabled ("off").
--- trace = "off" | "messages" | "verbose";
--- @field private _trace 'off'|'messages'|'verbose'
@@ -605,6 +602,10 @@ function Client:initialize()
local rpc = self.rpc
rpc.request('initialize', init_params, function(init_err, result)
-- Stopped before it answered: the server is being force-stopped (see Client:stop()).
if self._is_stopping then
return
end
if init_err then
error(lsp.rpc.format_rpc_error(init_err))
end
@@ -900,6 +901,8 @@ end
--- has not exited after `self.exit_timeout` milliseconds (unless `exit_timeout=false`).
--- Calling stop() on a client that was previously requested to shutdown, will escalate to
--- force-stop immediately, regardless of `force` (or `self.exit_timeout` if `force=nil`).
--- A server that has not answered "initialize" cannot receive "shutdown", so it is force-stopped
--- immediately.
---
--- Note: Forcing shutdown while a server is busy writing out project or index files can lead to
--- file corruption.
@@ -921,11 +924,12 @@ function Client:stop(force)
return
end
local was_stopping = self._is_stopping
self._is_stopping = true
lsp._watchfiles.cancel(self.id)
if force == true or not self.initialized or self._graceful_shutdown_failed then
if force == true or not self.initialized or was_stopping then
rpc.terminate()
return
end
@@ -944,7 +948,6 @@ function Client:stop(force)
else
-- If there was an error in the shutdown request, then terminate to be safe.
rpc.terminate()
self._graceful_shutdown_failed = true
end
end)
end
@@ -962,6 +965,10 @@ function Client:_restart(force)
local attached_buffers = vim.tbl_keys(self.attached_buffers)
vim.schedule(function()
-- Nvim is exiting: the exit handler would not stop a new client.
if vim.v.exiting ~= vim.NIL then
return
end
local new_client_id = lsp.start(self.config, { attach = false })
if new_client_id then
for _, buffer in ipairs(attached_buffers) do
@@ -1518,7 +1525,8 @@ function Client:_on_exit(code, signal)
if self then
changetracking.reset(self)
end
if code ~= 0 or (signal ~= 0 and signal ~= 15) then
-- The exit status of a stopped server does not indicate a crash.
if not self._is_stopping and (code ~= 0 or (signal ~= 0 and signal ~= 15)) then
local msg = string.format(
'Client %s quit with exit code %s and signal %s. Check log for errors: %s',
self and self.name or 'unknown',
+48
View File
@@ -94,6 +94,54 @@ describe(':lsp', function()
end)
end
it('stop, restart, disable a client that has not initialized', function()
local starts = t.tmpname()
eq(
{ { 'dummy' }, true, true, true },
exec_lua(function()
local server = _G._create_server({ handlers = { initialize = function() end } })
vim.lsp.config('dummy', {
cmd = function(...)
vim.fn.writefile({ 'start' }, starts, 'a')
return server.cmd(...)
end,
})
local function clients()
return vim.lsp.get_clients({ _uninitialized = true })
end
local function stopped()
return vim.wait(1000, function()
return #clients() == 0
end)
end
vim.lsp.enable('dummy')
local old_id = clients()[1].id
local completion = vim.fn.getcompletion('lsp stop ', 'cmdline')
vim.cmd('lsp restart dummy')
local restarted = vim.wait(1000, function()
return #clients() == 1 and clients()[1].id ~= old_id
end)
vim.cmd('lsp stop')
local stopped_by_stop = stopped()
vim.lsp.enable('dummy')
vim.cmd('lsp disable')
return { completion, restarted, stopped_by_stop, stopped() }
end)
)
-- No restart while Nvim exits: nothing would stop the new client.
n.expect_exit(exec_lua, function()
vim.fn.writefile({}, starts)
vim.lsp.enable('dummy')
vim.cmd('lsp restart dummy')
vim.cmd('qall!')
end)
eq('start\n', t.read_file(starts))
end)
it('subcommand completion', function()
local completions = exec_lua(function()
return vim.fn.getcompletion('lsp ', 'cmdline')
+61 -1
View File
@@ -118,7 +118,8 @@ describe('LSP', function()
local test_name = 'basic_init'
exec_lua(function()
_G.lsp = require('vim.lsp')
function _G.test__start_client()
--- @param timeout? string Server timeout in milliseconds.
function _G.test__start_client(timeout)
return vim.lsp.start({
cmd_env = {
NVIM_LOG_FILE = fake_lsp_logfile,
@@ -129,6 +130,7 @@ describe('LSP', function()
'-l',
fake_lsp_code,
test_name,
timeout,
},
workspace_folders = {
{
@@ -236,6 +238,64 @@ describe('LSP', function()
local c1, c2 = clients[1], clients[2]
eq(false, c1 == c2, 'Expected a fresh client while the old one is stopping')
end)
it('exit handler stops starting clients, not stopping ones #42440', function()
exec_lua(create_server_definition)
eq(
{
-- Not answered "initialize": force-stopped, no warning about the exit status.
process = { true, {} },
-- Same, and a late answer does not initialize it.
starting = { true, false, { 'initialize' } },
-- Not answered "shutdown": exit_timeout=false is kept; another stop() force-stops it.
stopping = { false, { 'initialize', 'initialized', 'shutdown' }, true },
},
exec_lua(function()
local warnings = {} --- @type string[]
vim.notify = function(msg)
table.insert(warnings, msg)
end
local respond --- @type fun(err?: lsp.ResponseError, result: any)
local starting = _G._create_server({
handlers = {
initialize = function(_, _, callback)
respond = callback
end,
},
})
local stopping = _G._create_server({ handlers = { shutdown = function() end } })
local function start(name, server)
local id = vim.lsp.start({ name = name, cmd = server.cmd }, { attach = false })
return assert(vim.lsp.get_client_by_id(assert(id)))
end
local function sent(server)
return vim.tbl_map(function(m)
return m.method
end, server.messages)
end
-- The server outlives the wait unless stopped.
local process = assert(vim.lsp.get_client_by_id(_G.test__start_client('60000')))
local client1, client2 = start('starting', starting), start('stopping', stopping)
client2:stop(false)
vim.api.nvim_exec_autocmds('VimLeavePre', { modeline = false })
respond(nil, { capabilities = {} })
local result = {
process = {
vim.wait(4000, function()
return vim.lsp.get_client_by_id(process.id) == nil
end),
warnings,
},
starting = { client1.rpc.is_closing(), client1.initialized or false, sent(starting) },
stopping = { client2.rpc.is_closing(), sent(stopping) },
}
client2:stop(false)
table.insert(result.stopping, client2.rpc.is_closing())
return result
end)
)
end)
end)
describe('basic_init test', function()