commit d30d91f3a49e19e61473b74e42adf68e9215220d
parent e9d03b92b67ca8c798c95efe9b6abc7dae0666b3
Author: luukvbaal <luukvbaal@gmail.com>
Date: Tue, 27 Jan 2026 00:18:51 +0100
fix(ui): only internal messages are unsafe #37462
Problem: Fast context for msg_show event inhibits vim.ui_attach from
displaying a stream of messages from a single command.
Solution: Remove fast context from msg_show events emitted as a result
of explicit API/command calls. The fast context was originally
introduced to prevent issues with internal messages.
Diffstat:
12 files changed, 215 insertions(+), 140 deletions(-)
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
@@ -801,8 +801,9 @@ vim.ui_attach({ns}, {opts}, {callback}) *vim.ui_attach()*
|ui-popupmenu| and the sections below for event format for respective
events.
- Callbacks for `msg_show` events are executed in |api-fast| context;
- showing the message should be scheduled.
+ Callbacks for `msg_show` events originating from internal messages (as
+ opposed to events from commands or API calls) are executed in |api-fast|
+ context; showing the message needs to be scheduled.
Excessive errors inside the callback will result in forced detachment.
diff --git a/runtime/lua/vim/_extui.lua b/runtime/lua/vim/_extui.lua
@@ -38,17 +38,19 @@ ext.msg = require('vim._extui.messages')
ext.cmd = require('vim._extui.cmdline')
local M = {}
-local function ui_callback(event, ...)
+local function ui_callback(redraw_msg, event, ...)
local handler = ext.msg[event] or ext.cmd[event]
ext.check_targets()
handler(...)
- -- Cmdline mode and non-empty showcmd requires an immediate redraw.
- if ext.cmd[event] or event == 'msg_showcmd' and select(1, ...)[1] then
+ -- Cmdline mode, non-fast message and non-empty showcmd require an immediate redraw.
+ if ext.cmd[event] or redraw_msg or (event == 'msg_showcmd' and select(1, ...)[1]) then
+ ext.redrawing = true
api.nvim__redraw({
flush = handler ~= ext.cmd.cmdline_hide or nil,
cursor = handler == ext.cmd[event] and true or nil,
win = handler == ext.cmd[event] and ext.wins.cmd or nil,
})
+ ext.redrawing = false
end
end
local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
@@ -86,10 +88,11 @@ function M.enable(opts)
if not (ext.msg[event] or ext.cmd[event]) then
return
end
- if vim.in_fast_event() then
- scheduled_ui_callback(event, ...)
+ -- Ensure cmdline is placed after a scheduled message in block mode.
+ if vim.in_fast_event() or (event == 'cmdline_show' and ext.cmd.srow > 0) then
+ scheduled_ui_callback(false, event, ...)
else
- ui_callback(event, ...)
+ ui_callback(event == 'msg_show', event, ...)
end
return true
end)
diff --git a/runtime/lua/vim/_extui/messages.lua b/runtime/lua/vim/_extui/messages.lua
@@ -37,6 +37,13 @@ local M = {
on_dialog_key = 0, -- vim.on_key namespace for paging in the dialog window.
}
+-- An external redraw indicates the start of a new batch of messages in the cmdline.
+api.nvim_set_decoration_provider(ext.ns, {
+ on_start = function()
+ M.cmd.count = ext.redrawing and M.cmd.count or 0
+ end,
+})
+
function M.msg:close()
self.width, M.virt.msg[M.virt.idx.dupe][1] = 1, nil
M.prev_msg = ext.cfg.msg.target == 'msg' and '' or M.prev_msg
@@ -182,53 +189,46 @@ end
-- We need to keep track of the current message column to be able to
-- append or overwrite messages for :echon or carriage returns.
-local col, will_full, hlopts = 0, false, { undo_restore = false, invalidate = true, priority = 1 }
+local col, hlopts = 0, { undo_restore = false, invalidate = true, priority = 1 }
+
--- Move messages to cmdline or pager to show in full.
local function msg_to_full(src)
- if will_full then
- return
- end
- will_full, M.prev_msg = true, ''
-
- vim.schedule(function()
- -- Copy and clear message from src to enlarged cmdline that is dismissed by any
- -- key press, or append to pager in case that is already open (not hidden).
- local hidden = api.nvim_win_get_config(ext.wins.pager).hide
- local tar = hidden and 'cmd' or 'pager'
- if tar ~= src then
- local srow = hidden and 0 or api.nvim_buf_line_count(ext.bufs.pager)
- local marks = api.nvim_buf_get_extmarks(ext.bufs[src], -1, 0, -1, { details = true })
- local lines = api.nvim_buf_get_lines(ext.bufs[src], 0, -1, false)
- api.nvim_buf_set_lines(ext.bufs[src], 0, -1, false, {})
- api.nvim_buf_set_lines(ext.bufs[tar], srow, -1, false, lines)
- for _, mark in ipairs(marks) do
- hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group
- api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, srow + mark[2], mark[3], hlopts)
- end
- if tar == 'cmd' and ext.cmd.highlighter then
- ext.cmd.highlighter.active[ext.bufs.cmd] = nil
- elseif tar == 'pager' then
- api.nvim_command('norm! G')
- end
- M.virt.msg[M.virt.idx.spill][1] = nil
- else
- for _, id in pairs(M.virt.ids) do
- api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, id)
- end
+ -- Copy and clear message from src to enlarged cmdline that is dismissed by any
+ -- key press, or append to pager in case that is already open (not hidden).
+ local hidden = api.nvim_win_get_config(ext.wins.pager).hide
+ local tar = hidden and 'cmd' or 'pager'
+ if tar ~= src then
+ local srow = hidden and 0 or api.nvim_buf_line_count(ext.bufs.pager)
+ local marks = api.nvim_buf_get_extmarks(ext.bufs[src], -1, 0, -1, { details = true })
+ local lines = api.nvim_buf_get_lines(ext.bufs[src], 0, -1, false)
+ api.nvim_buf_set_lines(ext.bufs[src], 0, -1, false, {})
+ api.nvim_buf_set_lines(ext.bufs[tar], srow, -1, false, lines)
+ for _, mark in ipairs(marks) do
+ hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group
+ api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, srow + mark[2], mark[3], hlopts)
end
- M.msg:close()
- M.set_pos(tar)
- M[src].count, col, will_full = 0, 0, false
- end)
+ if tar == 'cmd' and ext.cmd.highlighter then
+ ext.cmd.highlighter.active[ext.bufs.cmd] = nil
+ elseif tar == 'pager' then
+ api.nvim_command('norm! G')
+ end
+ M[src].count = 0
+ M.virt.msg[M.virt.idx.spill][1] = nil
+ else
+ for _, id in pairs(M.virt.ids) do
+ api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, id)
+ end
+ end
+ M.set_pos(tar)
end
+local reset_timer ---@type uv.uv_timer_t?
---@param tar 'cmd'|'dialog'|'msg'|'pager'
---@param content MsgContent
---@param replace_last boolean
---@param append boolean
function M.show_msg(tar, content, replace_last, append)
local msg, restart, cr, dupe, count = '', false, false, 0, 0
- append = append and col > 0
if M[tar] then -- tar == 'cmd'|'msg'
if tar == ext.cfg.msg.target then
@@ -244,7 +244,7 @@ function M.show_msg(tar, content, replace_last, append)
count = M[tar].count + ((restart or msg == '\n') and 0 or 1)
-- Ensure cmdline is clear when writing the first message.
- if tar == 'cmd' and not will_full and dupe == 0 and M.cmd.count == 0 and ext.cmd.srow == 0 then
+ if tar == 'cmd' and dupe == 0 and M.cmd.count == 0 and ext.cmd.srow == 0 then
api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
end
end
@@ -257,7 +257,7 @@ function M.show_msg(tar, content, replace_last, append)
local line_count = api.nvim_buf_line_count(ext.bufs[tar])
---@type integer Start row after last line in the target buffer, unless
---this is the first message, or in case of a repeated or replaced message.
- local row = M[tar] and count <= 1 and not will_full and (tar == 'cmd' and ext.cmd.erow or 0)
+ local row = M[tar] and count <= 1 and ext.cmd.srow == 0 and 0
or line_count - ((replace_last or restart or cr or append) and 1 or 0)
local curline = (cr or append) and api.nvim_buf_get_lines(ext.bufs[tar], row, row + 1, false)[1]
local start_row, width = row, M.msg.width
@@ -298,7 +298,6 @@ function M.show_msg(tar, content, replace_last, append)
local texth = api.nvim_win_text_height(ext.wins.msg, { start_row = start_row })
if texth.all > math.ceil(o.lines * 0.5) then
msg_to_full(tar)
- return
end
M.set_pos('msg')
@@ -314,10 +313,8 @@ function M.show_msg(tar, content, replace_last, append)
fn.clearmatches(ext.wins.cmd) -- Clear matchparen highlights.
if ext.cmd.srow > 0 then
-- In block mode the cmdheight is already dynamic, so just print the full message
- -- regardless of height. Spoof cmdline_show to put cmdline below message.
- ext.cmd.srow = ext.cmd.srow + 1 + row - start_row
- ext.cmd.cmdline_show({}, 0, ':', '', ext.cmd.indent, 0, 0)
- api.nvim__redraw({ flush = true, cursor = true, win = ext.wins.cmd })
+ -- regardless of height. Put cmdline below message.
+ ext.cmd.srow = row + 1
else
api.nvim_win_set_cursor(ext.wins.cmd, { 1, 0 }) -- ensure first line is visible
if ext.cmd.highlighter then
@@ -331,7 +328,6 @@ function M.show_msg(tar, content, replace_last, append)
if texth.all > ext.cmdheight then
msg_to_full(tar)
- return
end
end
end
@@ -345,10 +341,10 @@ function M.show_msg(tar, content, replace_last, append)
end
-- Reset message state the next event loop iteration.
- if start_row == 0 or ext.cmd.srow > 0 then
- vim.schedule(function()
- col, M.cmd.count = 0, 0
- end)
+ if not reset_timer and (col > 0 or M.cmd.count > 0) then
+ reset_timer = vim.defer_fn(function()
+ reset_timer, col, M.cmd.count = nil, 0, 0
+ end, 0)
end
end
@@ -452,9 +448,14 @@ function M.msg_history_show(entries, prev_cmd)
return
end
- if prev_cmd then
- M.msg_clear() -- Showing output of previous command, clear in case still visible.
+ if cmd_on_key then
+ -- Dismiss a still open full message cmd window.
+ api.nvim_feedkeys(vim.keycode('<CR>'), 'n', false)
+ elseif prev_cmd then
+ -- Showing output of previous command, clear in case still visible.
+ M.msg_clear()
end
+
api.nvim_buf_set_lines(ext.bufs.pager, 0, -1, false, {})
for i, entry in ipairs(entries) do
M.show_msg('pager', entry[2], i == 1, entry[3])
@@ -483,7 +484,7 @@ function M.set_pos(type)
}
api.nvim_win_set_config(win, config)
- if type == 'cmd' then
+ if type == 'cmd' and not cmd_on_key then
-- Temporarily showing a full message in the cmdline, until next key press.
local save_spill = M.virt.msg[M.virt.idx.spill][1]
local spill = texth.all > height and (' [+%d]'):format(texth.all - height)
diff --git a/runtime/lua/vim/_extui/shared.lua b/runtime/lua/vim/_extui/shared.lua
@@ -5,6 +5,7 @@ local M = {
ns = api.nvim_create_namespace('nvim._ext_ui'),
augroup = api.nvim_create_augroup('nvim._ext_ui', {}),
cmdheight = vim.o.cmdheight, -- 'cmdheight' option value set by user.
+ redrawing = false, -- True when redrawing to display UI event.
wins = { cmd = -1, dialog = -1, msg = -1, pager = -1 },
bufs = { cmd = -1, dialog = -1, msg = -1, pager = -1 },
cfg = {
diff --git a/runtime/lua/vim/_meta/builtin.lua b/runtime/lua/vim/_meta/builtin.lua
@@ -227,8 +227,9 @@ function vim.wait(time, callback, interval, fast_only) end
--- {callback} receives event name plus additional parameters. See |ui-popupmenu|
--- and the sections below for event format for respective events.
---
---- Callbacks for `msg_show` events are executed in |api-fast| context; showing
---- the message should be scheduled.
+--- Callbacks for `msg_show` events originating from internal messages (as
+--- opposed to events from commands or API calls) are executed in |api-fast|
+--- context; showing the message needs to be scheduled.
---
--- Excessive errors inside the callback will result in forced detachment.
---
diff --git a/runtime/lua/vim/health.lua b/runtime/lua/vim/health.lua
@@ -383,10 +383,6 @@ local function progress_report(len)
-- percent=0 omits the reporting of percentage, so use 1% instead
-- progress.percent = progress.percent == 0 and 1 or progress.percent
progress.id = vim.api.nvim_echo({ { fmt:format(...) } }, false, progress)
- -- extui/ui2 shows all messages at once after the healthchecks are finished.
- -- This 1ms wait ensures the messages are shown separately
- vim.wait(1)
- vim.cmd.redraw()
end
end
diff --git a/src/gen/gen_api_ui_events.lua b/src/gen/gen_api_ui_events.lua
@@ -136,7 +136,7 @@ for i = 1, #events do
call_output:write(' }\n')
call_output:write(' entered = true;\n')
write_arglist(call_output, ev)
- call_output:write((' ui_call_event("%s", %s, %s)'):format(ev.name, tostring(ev.fast), args))
+ call_output:write((' ui_call_event("%s", %s)'):format(ev.name, args))
call_output:write(';\n entered = false;\n')
elseif ev.compositor_impl then
call_output:write(' ui_comp_' .. ev.name)
diff --git a/src/nvim/api/ui_events.in.h b/src/nvim/api/ui_events.in.h
@@ -166,7 +166,7 @@ void wildmenu_hide(void)
void msg_show(String kind, Array content, Boolean replace_last, Boolean history, Boolean append,
Object id)
- FUNC_API_SINCE(6) FUNC_API_FAST FUNC_API_REMOTE_ONLY;
+ FUNC_API_SINCE(6) FUNC_API_REMOTE_ONLY;
void msg_clear(void)
FUNC_API_SINCE(6) FUNC_API_REMOTE_ONLY;
void msg_showcmd(Array content)
diff --git a/src/nvim/ui.c b/src/nvim/ui.c
@@ -741,8 +741,26 @@ static void ui_attach_error(uint32_t ns_id, const char *name, const char *msg)
msg_schedule_semsg_multiline("Error in \"%s\" UI event handler (ns=%s):\n%s", name, ns, msg);
}
-void ui_call_event(char *name, bool fast, Array args)
-{
+void ui_call_event(char *name, Array args)
+{
+ // Internal messages are considered unsafe and are executed in fast context.
+ bool fast = strcmp(name, "msg_show") == 0;
+ const char *not_fast[] = {
+ "empty",
+ "echo",
+ "echomsg",
+ "echoerr",
+ "list_cmd",
+ "lua_error",
+ "lua_print",
+ "progress",
+ NULL,
+ };
+
+ for (int i = 0; fast && not_fast[i]; i++) {
+ fast = !strequal(not_fast[i], args.items[0].data.string.data);
+ }
+
bool handled = false;
UIEventCallback *event_cb;
diff --git a/test/functional/lua/ui_event_spec.lua b/test/functional/lua/ui_event_spec.lua
@@ -429,7 +429,7 @@ describe('vim.ui_attach', function()
exec_lua([[
vim.ui_attach(vim.api.nvim_create_namespace(''), { ext_messages = true }, function(ev)
if ev == 'msg_show' then
- vim.api.nvim_buf_set_lines(0, -2, -1, false, { err[1] })
+ error('foo')
end
end)
]])
@@ -437,10 +437,13 @@ describe('vim.ui_attach', function()
screen:expect({
grid = [[
|
- {1:~ }|*5
+ {1:~ }|*2
{3: }|
- {9:Error in "msg_show" UI event handler (ns=(UNKNOWN PLUGIN)):} |
- {9:fast context failure} |
+ {9:Lua callback:} |
+ {9:[string "<nvim>"]:3: foo} |
+ {9:stack traceback:} |
+ {9: [C]: in function 'error'} |
+ {9: [string "<nvim>"]:3: in function <[string "<nvim>"]:1>} |
{100:Press ENTER or type command to continue}^ |
]],
condition = function()
@@ -448,17 +451,12 @@ describe('vim.ui_attach', function()
end,
})
feed('<Esc>')
- screen:expect([[
- ^ |
- {1:~ }|*8
- |
- ]])
-- Also when scheduled
exec_lua([[
vim.ui_attach(vim.api.nvim_create_namespace(''), { ext_messages = true }, function(ev)
if ev == 'msg_show' then
- vim.schedule(function() vim.api.nvim_buf_set_lines(0, -2, -1, false, { err[1] }) end)
+ vim.schedule(function() error('foo') end)
end
end)
]])
diff --git a/test/functional/ui/cmdline2_spec.lua b/test/functional/ui/cmdline2_spec.lua
@@ -64,7 +64,14 @@ describe('cmdline2', function()
{16::}{15:if} {26:1} |
{16::} ^ |
]])
- feed('echo "foo"<CR>')
+ feed('echo "foo"')
+ screen:expect([[
+ |
+ {1:~ }|*11
+ {16::}{15:if} {26:1} |
+ {16::} {15:echo} {26:"foo"}^ |
+ ]])
+ feed('<CR>')
screen:expect([[
|
{1:~ }|*9
@@ -73,13 +80,52 @@ describe('cmdline2', function()
{15:foo} |
{16::} ^ |
]])
+ feed([[echo input("foo\nbar:")<CR>]])
+ screen:expect([[
+ |
+ {1:~ }|*7
+ :if 1 |
+ : echo "foo" |
+ foo |
+ : echo input("foo\nbar:") |
+ foo |
+ bar:^ |
+ ]])
+ feed('baz')
+ screen:expect([[
+ |
+ {1:~ }|*7
+ :if 1 |
+ : echo "foo" |
+ foo |
+ : echo input("foo\nbar:") |
+ foo |
+ bar:baz^ |
+ ]])
+ feed('<CR>')
+ screen:expect([[
+ |
+ {1:~ }|*5
+ {16::}{15:if} {26:1} |
+ {16::} {15:echo} {26:"foo"} |
+ {15:foo} |
+ {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
+ {15:foo} |
+ {15:bar}:baz |
+ {15:baz} |
+ {16::} ^ |
+ ]])
feed('endif')
screen:expect([[
|
- {1:~ }|*9
+ {1:~ }|*5
{16::}{15:if} {26:1} |
{16::} {15:echo} {26:"foo"} |
{15:foo} |
+ {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
+ {15:foo} |
+ {15:bar}:baz |
+ {15:baz} |
{16::} {15:endif}^ |
]])
feed('<CR>')
diff --git a/test/functional/ui/messages2_spec.lua b/test/functional/ui/messages2_spec.lua
@@ -148,6 +148,19 @@ describe('messages2', function()
{1:~ }|*12
|
]])
+ -- A redraw indicates the start of messages in the cmdline, which empty should clear.
+ command('echo "foo" | redraw | echo "bar"')
+ screen:expect([[
+ ^ |
+ {1:~ }|*12
+ bar |
+ ]])
+ command('echo "foo" | redraw | echo ""')
+ screen:expect([[
+ ^ |
+ {1:~ }|*12
+ |
+ ]])
command('set cmdheight=0')
command('echo "foo"')
screen:expect([[
@@ -365,61 +378,6 @@ describe('messages2', function()
screen:expect(top)
end)
- it('in cmdline_block mode', function()
- feed(':if 1<CR>')
- screen:expect([[
- |
- {1:~ }|*11
- {16::}{15:if} {26:1} |
- {16::} ^ |
- ]])
- feed([[echo input("foo\nbar:")<CR>]])
- screen:expect([[
- |
- {1:~ }|*9
- :if 1 |
- : echo input("foo\nbar:") |
- foo |
- bar:^ |
- ]])
- feed('baz<CR>')
- screen:expect([[
- |
- {1:~ }|*9
- {16::}{15:if} {26:1} |
- {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
- {15:baz} |
- {16::} ^ |
- ]])
- feed([[echo input("foo\nbar:")<CR>]])
- screen:expect([[
- |
- {1:~ }|*7
- :if 1 |
- : echo input("foo\nbar:") |
- baz |
- : echo input("foo\nbar:") |
- foo |
- bar:^ |
- ]])
- feed('<Esc>:endif')
- screen:expect([[
- |
- {1:~ }|*8
- {16::}{15:if} {26:1} |
- {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
- {15:baz} |
- {16::} {15:echo} {25:input}{16:(}{26:"foo\nbar:"}{16:)} |
- {16::} {16::}{15:endif}^ |
- ]])
- feed('<CR>')
- screen:expect([[
- ^ |
- {1:~ }|*12
- |
- ]])
- end)
-
it('FileType is fired after default options are set', function()
n.exec([[
let g:set = {}
@@ -430,13 +388,12 @@ describe('messages2', function()
]])
screen:expect([[
|
- {1:~ }|*9
+ {1:~ }|*10
{3: }|
- ^foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofo|
- {1: }|
+ foofoofoofoofoofoofoofoofo^o |
|
]])
- t.eq({ filetype = 4 }, n.eval('g:set')) -- still fires for 'filetype'
+ t.eq({ filetype = 5 }, n.eval('g:set')) -- still fires for 'filetype'
end)
it('Search highlights only apply to pager', function()
@@ -467,4 +424,57 @@ describe('messages2', function()
{101:fo^o}{100: }|
]])
end)
+
+ it('shows message from still running command', function()
+ exec_lua(function()
+ vim.schedule(function()
+ print('foo')
+ vim.uv.sleep(100)
+ print('bar')
+ end)
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*12
+ foo |
+ ]])
+ screen:expect([[
+ ^ |
+ {1:~ }|*10
+ {3: }|
+ foo |
+ bar |
+ ]])
+ end)
+
+ it('properly formatted carriage return messages', function()
+ screen:try_resize(screen._width, 20)
+ command([[echon "\r" | echon "Hello" | echon " " | echon "World"]])
+ screen:expect([[
+ ^ |
+ {1:~ }|*18
+ Hello World |
+ ]])
+ exec_lua(function()
+ vim.api.nvim_echo({ { 'fooo\nbarbaz\n\nlol', 'statement' }, { '\rbar' } }, true, {})
+ vim.api.nvim_echo({ { 'foooooooo', 'statement' }, { 'baz\rb', 'error' } }, true, {})
+ vim.api.nvim_echo({ { 'fooobar', 'statement' }, { '\rbaz\n' } }, true, {})
+ vim.api.nvim_echo({ { 'fooobar', 'statement' }, { '\rbaz\rb', 'error' } }, true, {})
+ vim.api.nvim_echo({ { 'fooo\rbar', 'statement' }, { 'baz', 'error' } }, true, {})
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*9
+ {3: }|
+ {15:fooo} |
+ {15:barbaz} |
+ |
+ bar |
+ {9:b}{15:oooooooo}{9:baz} |
+ baz{15:obar} |
+ |
+ {9:baz}{15:obar} |
+ {15:bar}{9:baz} |
+ ]])
+ end)
end)