test(lsp): fix fake LSP server timeout not working (#37970)

Problem:  Fake LSP server does not timeout or respond to SIGTERM as it
          does not run the event loop.
Solution: Instead of io.read(), use stdioopen()'s on_stdin callback to
          accumulate input and use vim.wait() to wait for input.

Also, in the test suite, don't stop a session when it's not running, as
calling uv.stop() outside uv.run() will instead cause the next uv.run()
to stop immediately, which cancels the next RPC request.
This commit is contained in:
zeertzjq
2026-02-20 02:38:14 +08:00
committed by GitHub
parent 08f4811061
commit dec3c6fa34
4 changed files with 62 additions and 26 deletions

View File

@@ -1,10 +1,22 @@
local protocol = require 'vim.lsp.protocol'
local pid = vim.uv.os_getpid()
local stdin = ''
vim.fn.stdioopen({
on_stdin = function(_, data, _)
stdin = stdin .. table.concat(data, '\n')
end,
})
-- Logs to $NVIM_LOG_FILE.
--
-- TODO(justinmk): remove after https://github.com/neovim/neovim/pull/7062
local function log(loglevel, area, msg)
vim.fn.writefile({ string.format('%s %s: %s', loglevel, area, msg) }, vim.env.NVIM_LOG_FILE, 'a')
vim.fn.writefile(
{ string.format('%d %s %s: %s', pid, loglevel, area, msg) },
vim.env.NVIM_LOG_FILE,
'a'
)
end
local function message_parts(sep, ...)
@@ -46,10 +58,30 @@ local function format_message_with_content_length(encoded_message)
}
end
local function read_line()
vim.wait(math.huge, function()
return stdin:find('\n') ~= nil
end, 1)
local eol = assert(stdin:find('\n'))
local line = stdin:sub(1, eol - 1)
stdin = stdin:sub(eol + 1)
return line
end
--- @param len integer
local function read_len(len)
vim.wait(math.huge, function()
return stdin:len() >= len
end, 1)
local content = stdin:sub(1, len)
stdin = stdin:sub(len + 1)
return content
end
local function read_message()
local line = io.read('*l')
local line = read_line()
local length = line:lower():match('content%-length:%s*(%d+)')
return vim.json.decode(io.read(2 + length):sub(2))
return vim.json.decode(read_len(2 + length):sub(2))
end
local function send(payload)
@@ -1045,21 +1077,24 @@ end
-- Tests will be indexed by test_name
local test_name = arg[1]
local timeout = arg[2]
local timeout = tonumber(arg[2])
assert(type(test_name) == 'string', 'test_name must be specified as first arg.')
local kill_timer = assert(vim.uv.new_timer())
kill_timer:start(timeout or 1e3, 0, function()
kill_timer:stop()
kill_timer:close()
local kill_timer = vim.defer_fn(function()
log('ERROR', 'LSP', 'TIMEOUT')
io.stderr:write('TIMEOUT')
os.exit(100)
end)
end, timeout or 1e3)
-- Close the timer on exit (deadly signal or :cquit) to avoid delay with ASAN/TSAN.
vim.api.nvim_create_autocmd('VimLeave', {
callback = function()
kill_timer:stop()
kill_timer:close()
end,
})
local status, err = pcall(assert(tests[test_name], 'Test not found'))
kill_timer:stop()
kill_timer:close()
if not status then
log('ERROR', 'LSP', tostring(err))
io.stderr:write(err)

View File

@@ -110,6 +110,7 @@ M.create_server_definition = function()
function srv.terminate()
closing = true
dispatchers.on_exit(0, 15)
end
return srv

View File

@@ -88,7 +88,11 @@ describe('LSP', function()
after_each(function()
stop()
exec_lua('vim.iter(lsp.get_clients()):each(function(client) client:stop(true) end)')
exec_lua(function()
vim.iter(vim.lsp.get_clients({ _uninitialized = true })):each(function(client)
client:stop(true)
end)
end)
api.nvim_exec_autocmds('VimLeavePre', { modeline = false })
end)
@@ -206,18 +210,18 @@ describe('LSP', function()
it('does not reuse an already-stopping client #33616', function()
-- we immediately try to start a second client with the same name/root
-- before the first one has finished shutting down; we must get a new id.
local clients = exec_lua([[
local client1 = vim.lsp.start({
local clients = exec_lua(function()
local client1 = assert(vim.lsp.start({
name = 'dup-test',
cmd = { vim.v.progpath, '-l', fake_lsp_code, 'basic_init' },
}, { attach = false })
}, { attach = false }))
vim.lsp.get_client_by_id(client1):stop()
local client2 = vim.lsp.start({
local client2 = assert(vim.lsp.start({
name = 'dup-test',
cmd = { vim.v.progpath, '-l', fake_lsp_code, 'basic_init' },
}, { attach = false })
}, { attach = false }))
return { client1, client2 }
]])
end)
local c1, c2 = clients[1], clients[2]
eq(false, c1 == c2, 'Expected a fresh client while the old one is stopping')
end)
@@ -320,14 +324,8 @@ describe('LSP', function()
)
it('should succeed with manual shutdown', function()
if is_ci() then
pending('hangs the build on CI #14028, re-enable with freeze timeout #14204')
return
elseif t.skip_fragile(pending) then
return
end
local expected_handlers = {
{ NIL, {}, { method = 'shutdown', bufnr = 1, client_id = 1, version = 0 } },
{ NIL, {}, { method = 'shutdown', bufnr = 1, client_id = 1, request_id = 2, version = 0 } },
{ NIL, {}, { method = 'test', client_id = 1 } },
}
test_rpc_server {

View File

@@ -320,7 +320,9 @@ function M.run(request_cb, notification_cb, setup_cb, timeout)
end
function M.stop()
assert(session):stop()
if loop_running then
assert(session):stop()
end
end
-- Use for commands which expect nvim to quit.