From d0a262db88ab67941bdb26407bb0fd77ddcd86fb Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:21:25 -0500 Subject: [PATCH] feat(channel): add ChanClose event #40568 Problem: Plugins using RPC sockets cannot detect when the peer closes a `sockconnect()` channel, so reconnect logic has no reliable trigger. Solution: Add a `ChanClose` event with channel info before the channel is removed, matching the existing `ChanOpen`/`ChanInfo` event model. --- runtime/doc/autocmd.txt | 7 ++++ runtime/doc/news.txt | 1 + runtime/doc/options.txt | 1 + runtime/lua/vim/_meta/api_keysets.gen.lua | 1 + runtime/lua/vim/_meta/options.gen.lua | 1 + src/nvim/auevents.lua | 1 + src/nvim/channel.c | 10 ++++-- src/nvim/channel.h | 2 ++ src/nvim/msgpack_rpc/channel.c | 2 +- test/functional/core/channels_spec.lua | 42 ++++++++++++++++++++++- 10 files changed, 63 insertions(+), 5 deletions(-) diff --git a/runtime/doc/autocmd.txt b/runtime/doc/autocmd.txt index c5fa6b588b..b316cef456 100644 --- a/runtime/doc/autocmd.txt +++ b/runtime/doc/autocmd.txt @@ -397,6 +397,13 @@ BufWriteCmd Before writing the whole buffer to a file. BufWritePost After writing the whole buffer to a file (should undo the commands for BufWritePre). + *ChanClose* +ChanClose After a channel was closed, before it is removed. + This is triggered even when inside an + autocommand defined without |autocmd-nested|. + Sets these |v:event| keys: + info as from |nvim_get_chan_info()| + *ChanInfo* ChanInfo State of channel changed, for instance the client of a RPC channel described itself. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index d2c953d150..f9cc0835a4 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -219,6 +219,7 @@ EVENTS • |:delmarks| now triggers the |MarkSet| autocommand with line==col==0, same as |nvim_buf_del_mark()| +• |ChanClose| is triggered after a channel is closed, before it is removed. • |SessionWritePre| event emits just before |:mksession|. • |TextPutPre| and |TextPutPost| are triggered before/after putting text. • |TabMoved| is triggered when tabs are reordered. diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index c477423ed7..d9be554830 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -2544,6 +2544,7 @@ A jump table for the options with a short description can be found at |Q_op|. Note: The following events are considered to happen outside of a window context and thus cannot be ignored by 'eventignorewin': + |ChanClose|, |ChanInfo|, |ChanOpen|, |CmdUndefined|, diff --git a/runtime/lua/vim/_meta/api_keysets.gen.lua b/runtime/lua/vim/_meta/api_keysets.gen.lua index 905d908811..d844d286b4 100644 --- a/runtime/lua/vim/_meta/api_keysets.gen.lua +++ b/runtime/lua/vim/_meta/api_keysets.gen.lua @@ -102,6 +102,7 @@ error('Cannot require a meta file') --- |'BufWriteCmd' --- |'BufWritePost' --- |'BufWritePre' +--- |'ChanClose' --- |'ChanInfo' --- |'ChanOpen' --- |'CmdUndefined' diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index d8773efc3b..fd06ffe365 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -2191,6 +2191,7 @@ vim.go.ei = vim.go.eventignore --- Note: The following events are considered to happen outside of a --- window context and thus cannot be ignored by 'eventignorewin': --- +--- `ChanClose`, --- `ChanInfo`, --- `ChanOpen`, --- `CmdUndefined`, diff --git a/src/nvim/auevents.lua b/src/nvim/auevents.lua index f3b0dd2088..2497d90a17 100644 --- a/src/nvim/auevents.lua +++ b/src/nvim/auevents.lua @@ -22,6 +22,7 @@ return { BufWriteCmd = true, -- write buffer using command BufWritePost = true, -- after writing a buffer BufWritePre = true, -- before writing a buffer + ChanClose = false, ChanInfo = false, -- info was received about channel ChanOpen = false, -- channel was opened CmdUndefined = false, -- command undefined diff --git a/src/nvim/channel.c b/src/nvim/channel.c index 6481b5da27..dcd422aa13 100644 --- a/src/nvim/channel.c +++ b/src/nvim/channel.c @@ -263,7 +263,7 @@ void channel_create_event(Channel *chan, const char *ext_source) (void)ext_source; #endif - channel_info_changed(chan, true); + channel_event(chan, EVENT_CHANOPEN); } void channel_incref(Channel *chan) @@ -273,6 +273,11 @@ void channel_incref(Channel *chan) void channel_decref(Channel *chan) { + if (chan->refcount == 1 && !chan->did_close_event) { + chan->did_close_event = true; + channel_event(chan, EVENT_CHANCLOSE); + } + if (!(--chan->refcount)) { // delay free, so that libuv is done with the handles multiqueue_put(main_loop.events, free_channel_event, chan); @@ -951,9 +956,8 @@ static void term_close(void *data) multiqueue_put(chan->events, term_delayed_free, data); } -void channel_info_changed(Channel *chan, bool new_chan) +void channel_event(Channel *chan, event_T event) { - event_T event = new_chan ? EVENT_CHANOPEN : EVENT_CHANINFO; if (has_event(event)) { channel_incref(chan); multiqueue_put(main_loop.events, set_info_event, chan, (void *)(intptr_t)event); diff --git a/src/nvim/channel.h b/src/nvim/channel.h index 669b50cf86..309048fe92 100644 --- a/src/nvim/channel.h +++ b/src/nvim/channel.h @@ -4,6 +4,7 @@ #include #include +#include "nvim/autocmd_defs.h" #include "nvim/channel_defs.h" // IWYU pragma: keep #include "nvim/eval/typval_defs.h" #include "nvim/event/defs.h" @@ -44,6 +45,7 @@ struct Channel { bool callback_busy; bool callback_scheduled; + bool did_close_event; }; #include "channel.h.generated.h" diff --git a/src/nvim/msgpack_rpc/channel.c b/src/nvim/msgpack_rpc/channel.c index 090d84c8f4..5789329fa4 100644 --- a/src/nvim/msgpack_rpc/channel.c +++ b/src/nvim/msgpack_rpc/channel.c @@ -675,7 +675,7 @@ void rpc_set_client_info(uint64_t id, Dict info) chan->rpc.client_type = kClientTypeUnknown; } - channel_info_changed(chan, false); + channel_event(chan, EVENT_CHANINFO); } Dict rpc_client_info(Channel *chan) diff --git a/test/functional/core/channels_spec.lua b/test/functional/core/channels_spec.lua index a24baa6788..896e8146f8 100644 --- a/test/functional/core/channels_spec.lua +++ b/test/functional/core/channels_spec.lua @@ -33,7 +33,7 @@ describe('channels', function() source(init) end) - pending('can connect to socket', function() + it('can connect to socket', function() local server = n.new_session(true) set_session(server) local address = fn.serverlist()[1] @@ -62,6 +62,46 @@ describe('channels', function() eq({ 'notification', 'data', { id, { '' } } }, next_msg()) end) + it('emits ChanClose when RPC socket reaches EOF', function() + local client = get_session() + local server = n.new_session(true) + finally(function() + set_session(client) + if server then + server:close() + end + end) + set_session(server) + local address = fn.serverlist()[1] + set_session(client) + + api.nvim_set_var('address', address) + exec_lua(function() + _G.closed_event = nil + vim.api.nvim_create_autocmd('ChanClose', { + callback = function() + _G.closed_event = vim.deepcopy(vim.v.event.info) + end, + }) + end) + command("let g:id = sockconnect('pipe', address, {'rpc': v:true})") + local id = eval('g:id') + ok(id > 0) + eq(5, eval("rpcrequest(g:id, 'nvim_eval', '2+3')")) + + server:close() + server = nil + set_session(client) + retry(nil, 3000, function() + local info = exec_lua(function() + return _G.closed_event + end) + eq(id, info.id) + eq('socket', info.stream) + eq('rpc', info.mode) + end) + end) + it('dont crash due to garbage in rpc #23781', function() local client = get_session() local server = n.new_session(true)