fix(autocmd): deferred TermResponse lacks "data", may not fire (#37778)

Problem: TermResponse deferred due to blocked autocommands lacks "data" payload.
Also, it may not fire if a new v:termresponse reuses the same string address.

Solution: add it. Use the value of v:termresponse for "data.sequence". Replace
pointer comparisons with a flag.

The removal of "old_termresponse" comparisons is required to pass the test on
the CI, or locally for me when compiled in RelWithDebInfo.
This commit is contained in:
Sean Dewar
2026-02-08 21:43:50 +00:00
committed by GitHub
parent eaacdc9bdf
commit 19379d1255
3 changed files with 83 additions and 10 deletions

View File

@@ -67,10 +67,6 @@ void nvim_ui_term_event(uint64_t channel_id, String event, Object value, Error *
const String termresponse = value.data.string;
set_vim_var_string(VV_TERMRESPONSE, termresponse.data, (ptrdiff_t)termresponse.size);
MAXSIZE_TEMP_DICT(data, 1);
PUT_C(data, "sequence", value);
apply_autocmds_group(EVENT_TERMRESPONSE, NULL, NULL, true, AUGROUP_ALL, NULL, NULL,
&DICT_OBJ(data));
do_termresponse_autocmd(termresponse);
}
}

View File

@@ -105,7 +105,7 @@ static int autocmd_blocked = 0; // block all autocmds
static bool autocmd_nested = false;
static bool autocmd_include_groups = false;
static char *old_termresponse = NULL;
static bool termresponse_changed = false;
// Map of autocmd group names and ids.
// name -> ID
@@ -2033,13 +2033,22 @@ BYPASS_AU:
return retval;
}
void do_termresponse_autocmd(const String sequence)
{
MAXSIZE_TEMP_DICT(data, 1);
PUT_C(data, "sequence", STRING_OBJ(sequence));
apply_autocmds_group(EVENT_TERMRESPONSE, NULL, NULL, true, AUGROUP_ALL, NULL, NULL,
&DICT_OBJ(data));
termresponse_changed = true;
}
// Block triggering autocommands until unblock_autocmd() is called.
// Can be used recursively, so long as it's symmetric.
void block_autocmds(void)
{
// Remember the value of v:termresponse.
// Detect if v:termresponse is set while blocked.
if (!is_autocmd_blocked()) {
old_termresponse = get_vim_var_str(VV_TERMRESPONSE);
termresponse_changed = false;
}
autocmd_blocked++;
}
@@ -2051,8 +2060,11 @@ void unblock_autocmds(void)
// When v:termresponse was set while autocommands were blocked, trigger
// the autocommands now. Esp. useful when executing a shell command
// during startup (nvim -d).
if (!is_autocmd_blocked() && get_vim_var_str(VV_TERMRESPONSE) != old_termresponse) {
apply_autocmds(EVENT_TERMRESPONSE, NULL, NULL, false, curbuf);
if (!is_autocmd_blocked() && termresponse_changed && has_event(EVENT_TERMRESPONSE)) {
// Copied to a new allocation, as termresponse may be freed during the event.
const String sequence = cstr_to_string(get_vim_var_str(VV_TERMRESPONSE));
do_termresponse_autocmd(sequence);
api_free_string(sequence);
}
}

View File

@@ -2707,6 +2707,71 @@ describe('TUI', function()
end)
end)
it('TermResponse from unblock_autocmds() sets "data"', function()
if not child_exec_lua('return pcall(require, "ffi")') then
pending('N/A: missing LuaJIT FFI')
end
child_exec_lua([[
local ffi = require('ffi')
ffi.cdef[=[
void block_autocmds(void);
void unblock_autocmds(void);
]=]
ffi.C.block_autocmds()
vim.api.nvim_create_autocmd('TermResponse', {
once = true,
callback = function(ev)
_G.data = ev.data
end,
})
]])
feed_data('\027P0$r\027\\')
retry(nil, 4000, function()
eq('\027P0$r', child_exec_lua('return vim.v.termresponse'))
end)
eq(vim.NIL, child_exec_lua('return _G.data'))
child_exec_lua('require("ffi").C.unblock_autocmds()')
eq({ sequence = '\027P0$r' }, child_exec_lua('return _G.data'))
-- If TermResponse during TermResponse changes v:termresponse, data.sequence contains the actual
-- response that triggered the autocommand.
-- The second autocommand below forces a use-after-free when v:termresponse's value changes
-- during TermResponse if data.sequence didn't allocate its own copy.
child_exec_lua([[
require('ffi').C.block_autocmds()
vim.api.nvim_create_autocmd('TermResponse', {
once = true,
callback = function(ev)
_G.au1_termresponse1 = vim.v.termresponse
_G.au1_sequence1 = ev.data.sequence
local chan = vim.fn.sockconnect('pipe', vim.v.servername, { rpc = true })
vim.rpcrequest(chan, 'nvim_ui_term_event', 'termresponse', 'baz')
_G.au1_termresponse2 = vim.v.termresponse
_G.au1_sequence2 = ev.data.sequence
end,
})
_G.au2_sequences = {}
vim.api.nvim_create_autocmd('TermResponse', {
callback = function(ev)
table.insert(_G.au2_sequences, ev.data.sequence)
end,
})
]])
child_session:request('nvim_ui_term_event', 'termresponse', 'foobar')
eq('foobar', child_exec_lua('return vim.v.termresponse'))
-- For good measure, check deferred TermResponse doesn't try to fire if autocmds are still
-- blocked after unblock_autocmds.
child_exec_lua('require("ffi").C.block_autocmds() require("ffi").C.unblock_autocmds()')
eq(vim.NIL, child_exec_lua('return _G.au1_termresponse1'))
child_exec_lua('require("ffi").C.unblock_autocmds()')
eq('foobar', child_exec_lua('return _G.au1_termresponse1'))
eq('foobar', child_exec_lua('return _G.au1_sequence1'))
eq('baz', child_exec_lua('return _G.au1_termresponse2'))
eq('foobar', child_exec_lua('return _G.au1_sequence2')) -- unchanged
-- Second autocmd triggers due to "baz" (via the nested TermResponse), then from "foobar".
eq({ 'baz', 'foobar' }, child_exec_lua('return _G.au2_sequences'))
end)
it('nvim_ui_send works', function()
child_session:request('nvim_ui_send', '\027]2;TEST_TITLE\027\\')
retry(nil, nil, function()