commit 19379d1255554041eb6d67423cfa83ca23e5be42
parent eaacdc9bdffd98fff6ec788b5526a9c80d16f2cf
Author: Sean Dewar <6256228+seandewar@users.noreply.github.com>
Date: Sun, 8 Feb 2026 21:43:50 +0000
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.
Diffstat:
3 files changed, 83 insertions(+), 10 deletions(-)
diff --git a/src/nvim/api/events.c b/src/nvim/api/events.c
@@ -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);
}
}
diff --git a/src/nvim/autocmd.c b/src/nvim/autocmd.c
@@ -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);
}
}
diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua
@@ -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()