diff options
| author | Justin M. Keyes <justinkz@gmail.com> | 2026-04-23 17:47:27 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-23 17:47:27 -0400 |
| commit | bc288ee3e9a81b6e63ac5ab0116110cf5a6e56b0 (patch) | |
| tree | 34decd1068199880147994cc0310ba1056bc0d5d | |
| parent | 731f9743e2ec675c80ef554c1295494c51b5725a (diff) | |
| parent | f945aa451b9e094abf10dc72b03acb7b5c4fa206 (diff) | |
Merge pull request #39356 from justinmk/release
backports
| -rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 8 | ||||
| -rw-r--r-- | test/functional/ex_cmds/trust_spec.lua | 41 | ||||
| -rw-r--r-- | test/functional/lua/secure_spec.lua | 124 | ||||
| -rw-r--r-- | test/functional/plugin/lsp_spec.lua | 154 |
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() |
