summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorJustin M. Keyes <justinkz@gmail.com>2026-04-23 17:47:27 -0400
committerGitHub <noreply@github.com>2026-04-23 17:47:27 -0400
commitbc288ee3e9a81b6e63ac5ab0116110cf5a6e56b0 (patch)
tree34decd1068199880147994cc0310ba1056bc0d5d
parent731f9743e2ec675c80ef554c1295494c51b5725a (diff)
parentf945aa451b9e094abf10dc72b03acb7b5c4fa206 (diff)
Merge pull request #39356 from justinmk/release
backports
-rw-r--r--runtime/lua/vim/lsp/rpc.lua8
-rw-r--r--test/functional/ex_cmds/trust_spec.lua41
-rw-r--r--test/functional/lua/secure_spec.lua124
-rw-r--r--test/functional/plugin/lsp_spec.lua154
4 files changed, 239 insertions, 88 deletions
diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua
index 1cc5ffef44..761c0aac17 100644
--- a/runtime/lua/vim/lsp/rpc.lua
+++ b/runtime/lua/vim/lsp/rpc.lua
@@ -380,7 +380,9 @@ function Client:handle_body(body)
if type(decoded) ~= 'table' then
self:on_error(M.client_errors.INVALID_SERVER_MESSAGE, decoded)
- elseif type(decoded.method) == 'string' and decoded.id then
+ elseif type(decoded.method) == 'string' and decoded.id and decoded.id ~= vim.NIL then
+ local id_type = type(decoded.id)
+ assert(id_type == 'number' or id_type == 'string', 'Request id must be a number or a string')
local err --- @type lsp.ResponseError?
-- Schedule here so that the users functions don't trigger an error and
-- we can still use the result.
@@ -426,6 +428,7 @@ function Client:handle_body(body)
-- - If 'result' is nil, then 'error' must be present (and not vim.NIL).
elseif
decoded.id
+ and decoded.id ~= vim.NIL
and (
(decoded.error == nil and decoded.result ~= nil)
or (decoded.result == nil and decoded.error ~= nil and decoded.error ~= vim.NIL)
@@ -477,6 +480,9 @@ function Client:handle_body(body)
self:on_error(M.client_errors.NO_RESULT_CALLBACK_FOUND, decoded)
log.error('No callback found for server response id ' .. result_id)
end
+ elseif decoded.id == vim.NIL then
+ log.warn('Server sent response with null id', decoded.error)
+ self:on_error(M.client_errors.INVALID_SERVER_MESSAGE, decoded)
elseif type(decoded.method) == 'string' then
-- Notification
self:try_call(
diff --git a/test/functional/ex_cmds/trust_spec.lua b/test/functional/ex_cmds/trust_spec.lua
index 5b6210bd3c..8cfb276706 100644
--- a/test/functional/ex_cmds/trust_spec.lua
+++ b/test/functional/ex_cmds/trust_spec.lua
@@ -6,7 +6,6 @@ local clear = n.clear
local command = n.command
local exec_capture = n.exec_capture
local matches = t.matches
-local pathsep = n.get_pathsep()
local is_os = t.is_os
local fn = n.fn
@@ -15,7 +14,7 @@ describe(':trust', function()
local test_file = 'Xtest_functional_ex_cmds_trust'
before_each(function()
- n.mkdir_p(xstate .. pathsep .. (is_os('win') and 'nvim-data' or 'nvim'))
+ n.mkdir_p(vim.fs.joinpath(xstate, is_os('win') and 'nvim-data' or 'nvim'))
t.write_file(test_file, 'test')
clear { env = { XDG_STATE_HOME = xstate } }
end)
@@ -30,10 +29,22 @@ describe(':trust', function()
return s:format(test_file)
end
+ --- Joins path components using the OS-native separator.
+ --- Unlike vim.fs.joinpath (which normalizes to "/"), this preserves "\" on Windows
+ --- to match paths stored in the trust database.
+ local function osjoin(...)
+ return (table.concat({ ... }, n.get_pathsep()))
+ end
+
+ local function assert_trust_entry(expected)
+ local trust = t.read_file(vim.fs.joinpath(fn.stdpath('state'), 'trust'))
+ eq(expected, vim.trim(trust))
+ end
+
it('is not executed when inside false condition', function()
command(fmt('edit %s'))
eq('', exec_capture('if 0 | trust | endif'))
- eq(nil, vim.uv.fs_stat(fn.stdpath('state') .. pathsep .. 'trust'))
+ eq(nil, vim.uv.fs_stat(vim.fs.joinpath(fn.stdpath('state'), 'trust')))
end)
it('trust then deny then remove a file using current buffer', function()
@@ -42,16 +53,13 @@ describe(':trust', function()
command(fmt('edit %s'))
matches(fmt('^Allowed in trust database%%: ".*%s"$'), exec_capture('trust'))
- local trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format('%s %s', hash, cwd .. pathsep .. test_file), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, osjoin(cwd, test_file)))
matches(fmt('^Denied in trust database%%: ".*%s"$'), exec_capture('trust ++deny'))
- trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format('! %s', cwd .. pathsep .. test_file), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(osjoin(cwd, test_file)))
matches(fmt('^Removed from trust database%%: ".*%s"$'), exec_capture('trust ++remove'))
- trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format(''), vim.trim(trust))
+ assert_trust_entry('')
end)
it('deny then trust then remove a file using current buffer', function()
@@ -60,27 +68,22 @@ describe(':trust', function()
command(fmt('edit %s'))
matches(fmt('^Denied in trust database%%: ".*%s"$'), exec_capture('trust ++deny'))
- local trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format('! %s', cwd .. pathsep .. test_file), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(osjoin(cwd, test_file)))
matches(fmt('^Allowed in trust database%%: ".*%s"$'), exec_capture('trust'))
- trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format('%s %s', hash, cwd .. pathsep .. test_file), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, osjoin(cwd, test_file)))
matches(fmt('^Removed from trust database%%: ".*%s"$'), exec_capture('trust ++remove'))
- trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format(''), vim.trim(trust))
+ assert_trust_entry('')
end)
it('deny then remove a file using file path', function()
local cwd = fn.getcwd()
matches(fmt('^Denied in trust database%%: ".*%s"$'), exec_capture(fmt('trust ++deny %s')))
- local trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format('! %s', cwd .. pathsep .. test_file), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(osjoin(cwd, test_file)))
matches(fmt('^Removed from trust database%%: ".*%s"$'), exec_capture(fmt('trust ++remove %s')))
- trust = t.read_file(fn.stdpath('state') .. pathsep .. 'trust')
- eq(string.format(''), vim.trim(trust))
+ assert_trust_entry('')
end)
end)
diff --git a/test/functional/lua/secure_spec.lua b/test/functional/lua/secure_spec.lua
index c7cb1005fd..c68637f00a 100644
--- a/test/functional/lua/secure_spec.lua
+++ b/test/functional/lua/secure_spec.lua
@@ -5,7 +5,6 @@ local Screen = require('test.functional.ui.screen')
local eq = t.eq
local clear = n.clear
local command = n.command
-local pathsep = n.get_pathsep()
local is_os = t.is_os
local api = n.api
local exec_lua = n.exec_lua
@@ -18,13 +17,25 @@ local matches = t.matches
local read_file = t.read_file
describe('vim.secure', function()
+ --- Joins path components using the OS-native separator.
+ --- Unlike vim.fs.joinpath (which normalizes to "/"), this preserves "\" on Windows
+ --- to match paths stored in the trust database.
+ local function osjoin(...)
+ return (table.concat({ ... }, n.get_pathsep()))
+ end
+
+ local function assert_trust_entry(expected)
+ local trust = assert(read_file(vim.fs.joinpath(stdpath('state'), 'trust')))
+ eq(expected, vim.trim(trust))
+ end
+
describe('read()', function()
local xstate = 'Xstate_lua_secure'
local screen ---@type test.functional.ui.screen
before_each(function()
clear { env = { XDG_STATE_HOME = xstate } }
- n.mkdir_p(xstate .. pathsep .. (is_os('win') and 'nvim-data' or 'nvim'))
+ n.mkdir_p(vim.fs.joinpath(xstate, is_os('win') and 'nvim-data' or 'nvim'))
t.mkdir('Xdir')
t.mkdir('Xdir/Xsubdir')
@@ -56,7 +67,7 @@ describe('vim.secure', function()
local cwd = fn.getcwd()
local msg = 'exrc: Found untrusted code. To enable it, choose (v)iew then run `:trust`:'
- local path = ('%s%sXfile'):format(cwd, pathsep)
+ local path = osjoin(cwd, 'Xfile')
-- Need to use feed_command instead of exec_lua because of the confirmation prompt
feed_command([[lua vim.secure.read('Xfile')]])
@@ -76,11 +87,10 @@ describe('vim.secure', function()
{MATCH: +}|
]])
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', cwd .. pathsep .. 'Xfile'), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(osjoin(cwd, 'Xfile')))
eq(vim.NIL, exec_lua([[return vim.secure.read('Xfile')]]))
- os.remove(stdpath('state') .. pathsep .. 'trust')
+ os.remove(vim.fs.joinpath(stdpath('state'), 'trust'))
feed_command([[lua vim.secure.read('Xfile')]])
screen:expect([[
@@ -97,21 +107,20 @@ describe('vim.secure', function()
screen:expect([[
^let g:foobar = 42{MATCH: +}|
{1:~{MATCH: +}}|*2
- {2:]] .. fn.fnamemodify(cwd, ':~') .. pathsep .. [[Xfile [RO]{MATCH: +}}|
+ {2:]] .. osjoin(fn.fnamemodify(cwd, ':~'), 'Xfile') .. [[ [RO]{MATCH: +}}|
{MATCH: +}|
{1:~{MATCH: +}}|
{4:[No Name]{MATCH: +}}|
- Allowed in trust database: "]] .. cwd .. pathsep .. [[Xfile"{MATCH: +}|
+ Allowed in trust database: "]] .. osjoin(cwd, 'Xfile') .. [["{MATCH: +}|
]])
-- close the split for the next test below.
feed(':q<CR>')
local hash = fn.sha256(assert(read_file('Xfile')))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('%s %s', hash, cwd .. pathsep .. 'Xfile'), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, osjoin(cwd, 'Xfile')))
eq('let g:foobar = 42\n', exec_lua([[return vim.secure.read('Xfile')]]))
- os.remove(stdpath('state') .. pathsep .. 'trust')
+ os.remove(vim.fs.joinpath(stdpath('state'), 'trust'))
feed_command([[lua vim.secure.read('Xfile')]])
screen:expect([[
@@ -131,7 +140,7 @@ describe('vim.secure', function()
]])
-- Trust database is not updated
- eq(nil, read_file(stdpath('state') .. pathsep .. 'trust'))
+ eq(nil, read_file(vim.fs.joinpath(stdpath('state'), 'trust')))
feed_command([[lua vim.secure.read('Xfile')]])
screen:expect([[
@@ -147,7 +156,7 @@ describe('vim.secure', function()
screen:expect([[
^let g:foobar = 42{MATCH: +}|
{1:~{MATCH: +}}|*2
- {2:]] .. fn.fnamemodify(cwd, ':~') .. pathsep .. [[Xfile [RO]{MATCH: +}}|
+ {2:]] .. osjoin(fn.fnamemodify(cwd, ':~'), 'Xfile') .. [[ [RO]{MATCH: +}}|
{MATCH: +}|
{1:~{MATCH: +}}|
{4:[No Name]{MATCH: +}}|
@@ -155,7 +164,7 @@ describe('vim.secure', function()
]])
-- Trust database is not updated
- eq(nil, read_file(stdpath('state') .. pathsep .. 'trust'))
+ eq(nil, read_file(vim.fs.joinpath(stdpath('state'), 'trust')))
-- Cannot write file
pcall_err(command, 'write')
@@ -173,7 +182,7 @@ describe('vim.secure', function()
local cwd = fn.getcwd()
local msg =
'exrc: Found untrusted code. DIRECTORY trust is decided only by name, not contents:'
- local path = ('%s%sXdir'):format(cwd, pathsep)
+ local path = osjoin(cwd, 'Xdir')
-- Need to use feed_command instead of exec_lua because of the confirmation prompt
feed_command([[lua vim.secure.read('Xdir')]])
@@ -193,11 +202,10 @@ describe('vim.secure', function()
{MATCH: +}|
]])
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', cwd .. pathsep .. 'Xdir'), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(osjoin(cwd, 'Xdir')))
eq(vim.NIL, exec_lua([[return vim.secure.read('Xdir')]]))
- os.remove(stdpath('state') .. pathsep .. 'trust')
+ os.remove(vim.fs.joinpath(stdpath('state'), 'trust'))
feed_command([[lua vim.secure.read('Xdir')]])
screen:expect([[
@@ -218,12 +226,10 @@ describe('vim.secure', function()
-- Directories aren't hashed in the trust database, instead a slug ("directory") is stored
-- instead.
- local expected_hash = 'directory'
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('%s %s', expected_hash, cwd .. pathsep .. 'Xdir'), vim.trim(trust))
+ assert_trust_entry(('directory %s'):format(osjoin(cwd, 'Xdir')))
eq(true, exec_lua([[return vim.secure.read('Xdir')]]))
- os.remove(stdpath('state') .. pathsep .. 'trust')
+ os.remove(vim.fs.joinpath(stdpath('state'), 'trust'))
feed_command([[lua vim.secure.read('Xdir')]])
screen:expect([[
@@ -243,7 +249,7 @@ describe('vim.secure', function()
]])
-- Trust database is not updated
- eq(nil, read_file(stdpath('state') .. pathsep .. 'trust'))
+ eq(nil, read_file(vim.fs.joinpath(stdpath('state'), 'trust')))
feed_command([[lua vim.secure.read('Xdir')]])
screen:expect([[
@@ -259,7 +265,7 @@ describe('vim.secure', function()
screen:expect([[
^{MATCH: +}|
{1:~{MATCH: +}}|*2
- {2:]] .. fn.fnamemodify(cwd, ':~') .. pathsep .. [[Xdir [RO]{MATCH: +}}|
+ {2:]] .. osjoin(fn.fnamemodify(cwd, ':~'), 'Xdir') .. [[ [RO]{MATCH: +}}|
{MATCH: +}|
{1:~{MATCH: +}}|
{4:[No Name]{MATCH: +}}|
@@ -267,7 +273,7 @@ describe('vim.secure', function()
]])
-- Trust database is not updated
- eq(nil, read_file(stdpath('state') .. pathsep .. 'trust'))
+ eq(nil, read_file(vim.fs.joinpath(stdpath('state'), 'trust')))
end)
end)
@@ -281,7 +287,7 @@ describe('vim.secure', function()
end)
before_each(function()
- n.mkdir_p(xstate .. pathsep .. (is_os('win') and 'nvim-data' or 'nvim'))
+ n.mkdir_p(vim.fs.joinpath(xstate, is_os('win') and 'nvim-data' or 'nvim'))
t.write_file(test_file, 'test')
t.mkdir(test_dir)
end)
@@ -309,116 +315,101 @@ describe('vim.secure', function()
it('trust then deny then remove a file using bufnr', function()
local cwd = fn.getcwd()
local hash = fn.sha256(assert(read_file(test_file)))
- local full_path = cwd .. pathsep .. test_file
+ local full_path = osjoin(cwd, test_file)
command('edit ' .. test_file)
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('%s %s', hash, full_path), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, full_path))
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='deny', bufnr=0})}]]))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', full_path), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(full_path))
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='remove', bufnr=0})}]]))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq('', vim.trim(trust))
+ assert_trust_entry('')
end)
it('deny then trust then remove a file using bufnr', function()
local cwd = fn.getcwd()
local hash = fn.sha256(assert(read_file(test_file)))
- local full_path = cwd .. pathsep .. test_file
+ local full_path = osjoin(cwd, test_file)
command('edit ' .. test_file)
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='deny', bufnr=0})}]]))
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', full_path), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(full_path))
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('%s %s', hash, full_path), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, full_path))
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='remove', bufnr=0})}]]))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq('', vim.trim(trust))
+ assert_trust_entry('')
end)
it('trust using bufnr then deny then remove a file using path', function()
local cwd = fn.getcwd()
local hash = fn.sha256(assert(read_file(test_file)))
- local full_path = cwd .. pathsep .. test_file
+ local full_path = osjoin(cwd, test_file)
command('edit ' .. test_file)
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('%s %s', hash, full_path), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, full_path))
eq(
{ true, full_path },
exec_lua([[return {vim.secure.trust({action='deny', path=...})}]], test_file)
)
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', full_path), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(full_path))
eq(
{ true, full_path },
exec_lua([[return {vim.secure.trust({action='remove', path=...})}]], test_file)
)
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq('', vim.trim(trust))
+ assert_trust_entry('')
end)
it('trust then deny then remove a file using path', function()
local cwd = fn.getcwd()
local hash = fn.sha256(assert(read_file(test_file)))
- local full_path = cwd .. pathsep .. test_file
+ local full_path = osjoin(cwd, test_file)
eq(
{ true, full_path },
exec_lua([[return {vim.secure.trust({action='allow', path=...})}]], test_file)
)
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('%s %s', hash, full_path), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, full_path))
eq(
{ true, full_path },
exec_lua([[return {vim.secure.trust({action='deny', path=...})}]], test_file)
)
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', full_path), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(full_path))
eq(
{ true, full_path },
exec_lua([[return {vim.secure.trust({action='remove', path=...})}]], test_file)
)
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq('', vim.trim(trust))
+ assert_trust_entry('')
end)
it('deny then trust then remove a file using bufnr', function()
local cwd = fn.getcwd()
local hash = fn.sha256(assert(read_file(test_file)))
- local full_path = cwd .. pathsep .. test_file
+ local full_path = osjoin(cwd, test_file)
command('edit ' .. test_file)
eq(
{ true, full_path },
exec_lua([[return {vim.secure.trust({action='deny', path=...})}]], test_file)
)
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', full_path), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(full_path))
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('%s %s', hash, full_path), vim.trim(trust))
+ assert_trust_entry(('%s %s'):format(hash, full_path))
eq(
{ true, full_path },
exec_lua([[return {vim.secure.trust({action='remove', path=...})}]], test_file)
)
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq('', vim.trim(trust))
+ assert_trust_entry('')
end)
it('trust returns error when buffer not associated to file', function()
@@ -431,20 +422,17 @@ describe('vim.secure', function()
it('trust then deny then remove a directory using bufnr', function()
local cwd = fn.getcwd()
- local full_path = cwd .. pathsep .. test_dir
+ local full_path = osjoin(cwd, test_dir)
command('edit ' .. test_dir)
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='allow', bufnr=0})}]]))
- local trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('directory %s', full_path), vim.trim(trust))
+ assert_trust_entry(('directory %s'):format(full_path))
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='deny', bufnr=0})}]]))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq(string.format('! %s', full_path), vim.trim(trust))
+ assert_trust_entry(('! %s'):format(full_path))
eq({ true, full_path }, exec_lua([[return {vim.secure.trust({action='remove', bufnr=0})}]]))
- trust = assert(read_file(stdpath('state') .. pathsep .. 'trust'))
- eq('', vim.trim(trust))
+ assert_trust_entry('')
end)
end)
end)
diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua
index 84eb63beea..cd6629b378 100644
--- a/test/functional/plugin/lsp_spec.lua
+++ b/test/functional/plugin/lsp_spec.lua
@@ -3040,6 +3040,160 @@ describe('LSP', function()
}
eq(expected, result)
end)
+
+ it('does not crash on error response with null id (JSON-RPC 2.0 parse error)', function()
+ local result = exec_lua(function()
+ local server = assert(vim.uv.new_tcp())
+ local accepted
+ local messages = {}
+ server:bind('127.0.0.1', 0)
+ server:listen(127, function(err)
+ assert(not err, err)
+ accepted = assert(vim.uv.new_tcp())
+ server:accept(accepted)
+ accepted:read_start(require('vim.lsp.rpc').create_read_loop(function(body)
+ local payload = vim.json.decode(body)
+ if payload.method then
+ table.insert(messages, payload.method)
+ if payload.method == 'initialize' then
+ -- Send a valid initialize response first
+ local msg = vim.json.encode({
+ id = payload.id,
+ jsonrpc = '2.0',
+ result = {
+ capabilities = {},
+ },
+ })
+ accepted:write(
+ table.concat({ 'Content-Length: ', tostring(#msg), '\r\n\r\n', msg })
+ )
+ elseif payload.method == 'initialized' then
+ -- Then send an error response with null id (parse error per JSON-RPC 2.0 §5)
+ local msg =
+ '{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}'
+ accepted:write(
+ table.concat({ 'Content-Length: ', tostring(#msg), '\r\n\r\n', msg })
+ )
+ end
+ end
+ end, function()
+ if accepted and not accepted:is_closing() then
+ accepted:close()
+ end
+ end, function()
+ if accepted and not accepted:is_closing() then
+ accepted:close()
+ end
+ end))
+ end)
+ local port = server:getsockname().port
+ local on_error_called = false
+ local client_id = assert(vim.lsp.start({
+ name = 'null-id-test',
+ cmd = vim.lsp.rpc.connect('127.0.0.1', port),
+ on_error = function(_code, _err)
+ on_error_called = true
+ end,
+ }))
+ vim.lsp.get_client_by_id(client_id)
+ -- Wait for the initialized notification to be sent and the null-id error to be received
+ vim.wait(1000, function()
+ return #messages >= 2 and on_error_called
+ end)
+ if accepted and not accepted:is_closing() then
+ accepted:close()
+ end
+ server:close()
+ server:shutdown()
+ return {
+ messages = messages,
+ on_error_called = on_error_called,
+ }
+ end)
+ -- The key assertion: Neovim should not crash, and the error handler should be called
+ eq(true, result.on_error_called)
+ eq(true, #result.messages >= 2)
+ end)
+
+ it('does not misclassify server request with null id as notification', function()
+ local result = exec_lua(function()
+ local server = assert(vim.uv.new_tcp())
+ local accepted
+ local messages = {}
+ server:bind('127.0.0.1', 0)
+ server:listen(127, function(err)
+ assert(not err, err)
+ accepted = assert(vim.uv.new_tcp())
+ server:accept(accepted)
+ accepted:read_start(require('vim.lsp.rpc').create_read_loop(function(body)
+ local payload = vim.json.decode(body)
+ if payload.method then
+ table.insert(messages, payload.method)
+ if payload.method == 'initialize' then
+ local msg = vim.json.encode({
+ id = payload.id,
+ jsonrpc = '2.0',
+ result = {
+ capabilities = {},
+ },
+ })
+ accepted:write(
+ table.concat({ 'Content-Length: ', tostring(#msg), '\r\n\r\n', msg })
+ )
+ elseif payload.method == 'initialized' then
+ -- Send a server request with null id (invalid per JSON-RPC 2.0)
+ local msg =
+ '{"jsonrpc":"2.0","method":"workspace/configuration","params":{"items":[]},"id":null}'
+ accepted:write(
+ table.concat({ 'Content-Length: ', tostring(#msg), '\r\n\r\n', msg })
+ )
+ end
+ end
+ end, function()
+ if accepted and not accepted:is_closing() then
+ accepted:close()
+ end
+ end, function()
+ if accepted and not accepted:is_closing() then
+ accepted:close()
+ end
+ end))
+ end)
+ local port = server:getsockname().port
+ local on_error_called = false
+ local notification_received = false
+ local client_id = assert(vim.lsp.start({
+ name = 'null-id-request-test',
+ cmd = vim.lsp.rpc.connect('127.0.0.1', port),
+ on_error = function(_code, _err)
+ on_error_called = true
+ end,
+ handlers = {
+ ['workspace/configuration'] = function()
+ notification_received = true
+ return {}
+ end,
+ },
+ }))
+ vim.lsp.get_client_by_id(client_id)
+ vim.wait(1000, function()
+ return #messages >= 2 and (on_error_called or notification_received)
+ end)
+ if accepted and not accepted:is_closing() then
+ accepted:close()
+ end
+ server:close()
+ server:shutdown()
+ return {
+ messages = messages,
+ on_error_called = on_error_called,
+ notification_received = notification_received,
+ }
+ end)
+ -- Should be dispatched as an error, NOT silently handled as a notification
+ eq(true, result.on_error_called)
+ eq(false, result.notification_received)
+ end)
end)
describe('#dynamic vim.lsp._dynamic', function()