mirror of
https://github.com/neovim/neovim.git
synced 2026-11-06 05:16:52 +00:00
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:
1 parent
8876e363ae
commit
48dcaab903
6 files changed
+143
-20
No files matched your search
+7
-3
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user