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.
This commit is contained in:
luukvbaal
2026-01-27 00:18:51 +01:00
committed by GitHub
parent e9d03b92b6
commit d30d91f3a4
12 changed files with 215 additions and 140 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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.
---

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;

View File

@@ -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)
]])

View File

@@ -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('endif')
feed([[echo input("foo\nbar:")<CR>]])
screen:expect([[
|
{1:~ }|*9
{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:~ }|*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>')

View File

@@ -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)