From 85894471594936afe90b08c3a382c36dd166fa47 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Tue, 4 Aug 2026 06:20:16 -0400 Subject: [PATCH] feat(detach): opt-in to "server keeps running" #41133 Problem: By default, `nvim` does not survive if its host terminal dies. This is inconvenient if you want to use Nvim as a "session manager" (like tmux). Solution: Let users opt-in to the "survive" behavior via `:detach!` (bang "!"). This marks the current UI as "detachable", so the server will not self-exit if the UI channel closes. --- .emmyrc.json | 1 + runtime/doc/gui.txt | 38 ++++++++++++-------- runtime/doc/news.txt | 4 +++ src/nvim/ex_cmds.lua | 2 +- src/nvim/ex_docmd.c | 10 ++++-- src/nvim/msgpack_rpc/channel.c | 17 +++++++++ test/functional/terminal/tui_spec.lua | 50 ++++++++++++++++++++++----- 7 files changed, 94 insertions(+), 28 deletions(-) diff --git a/.emmyrc.json b/.emmyrc.json index 34d1e0b860..8ddce1f0da 100644 --- a/.emmyrc.json +++ b/.emmyrc.json @@ -12,6 +12,7 @@ }, "diagnostics": { "disable": [ + "preferred-local-alias", "unnecessary-if" ] }, diff --git a/runtime/doc/gui.txt b/runtime/doc/gui.txt index 376c37d9b9..98a80dd7e0 100644 --- a/runtime/doc/gui.txt +++ b/runtime/doc/gui.txt @@ -60,27 +60,35 @@ Use cases: Stop or detach the current UI *:detach* *E5768* -:[range]detach - Detaches the current UI. Other UIs (if any) remain attached. - Fails with "E5768" if no UI is attached. If [range] is "%", - all UIs _except_ the current UI are detached: >vim +:[range]detach Detaches the current UI. Other UIs (if any) remain attached, + unless [range] was given. If [range] is "%", all UIs _except_ + the current UI are detached: >vim :%detach < The server (typically `nvim --embed`) continues running as - a background process, and you can reattach to it later. - Before detaching, you may want to note the server address: + a background process. Use |:connect| or |--remote-ui| to + reattach. Before detaching, you may want to note the server + address: >vim :echo v:servername < - When [range] covers the entire buffer (`%`, as in `:%detach`), - all other UIs are detached, keeping only the current one. The - command fails with |E16| for any other range. No-op if only - one UI is attached. - Note: The server closes the UI RPC channel, so :detach works - for any UI client. But if the client isn't expecting this, it - may (incorrectly) report an error. + for any GUI/UI; but if the client isn't expecting this, it may + (incorrectly) report an error. + + *:detach!* +:detach! Marks the current UI as "detachable" without detaching it now: + the UI stays attached, but if it later disconnects + unexpectedly (e.g. host terminal closed, ssh disconnect, + etc.), the server keeps running in the background. + Use |:connect| or |--remote-ui| to reattach. + + If you always want Nvim to survive accidental disconnect, put + this in your config: >vim + + silent detach! +< ------------------------------------------------------------------------------ Restart Nvim @@ -118,8 +126,8 @@ Connect UI to a different server *:connect* :connect {address} - Detaches the UI from the server it is currently attached to - and attaches it to the server at {address} instead. + Attaches the UI to another Nvim instance: detaches from the + current server, then attaches to {address}. Note: If the current UI hasn't implemented the "connect" UI event, this command is equivalent to |:detach|. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 86b3b6846a..1563b28496 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -256,6 +256,10 @@ EDITOR • |:restart!| (with a bang "!") does not save/restore the session. • |ZR| restarts Nvim (|:restart|). • |:detach| with range "%" detaches all UIs except the current one. +• |:detach!| (with bang "!") marks the current UI as detachable: Nvim will + continue running if the UI disconnects unexpectedly (e.g. if you + accidentally close your terminal, ssh connection lost, etc.). + Use |:connect| to reattach. • |:uptime| displays uptime. • |:packupdate| and |:packdel| for managing |vim.pack|. • 'scrollback' is now also valid in |prompt-buffer| buffers to limit the diff --git a/src/nvim/ex_cmds.lua b/src/nvim/ex_cmds.lua index dc5c478500..bba712b334 100644 --- a/src/nvim/ex_cmds.lua +++ b/src/nvim/ex_cmds.lua @@ -742,7 +742,7 @@ M.cmds = { }, { command = 'detach', - flags = bit.bor(RANGE, TRLBAR), + flags = bit.bor(BANG, RANGE, TRLBAR), addr_type = 'ADDR_OTHER', func = 'ex_detach', }, diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index b5202902d0..a446d2ecf7 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -5825,11 +5825,15 @@ static void ex_tabs(exarg_T *eap) /// ":%detach" detaches all UIs _except_ the current UI. static void ex_detach(exarg_T *eap) { - if (!current_ui) { + Channel *chan = find_channel(current_ui); + if (!chan) { emsg(_(e_noui)); return; - } else if (eap && eap->forceit) { - emsg("bang (!) not supported yet"); + } + + if (eap && eap->forceit) { + chan->detach = true; + msg(_("Nvim will continue running if the UI disconnects"), 0); return; } diff --git a/src/nvim/msgpack_rpc/channel.c b/src/nvim/msgpack_rpc/channel.c index 5789329fa4..36e39e1b4a 100644 --- a/src/nvim/msgpack_rpc/channel.c +++ b/src/nvim/msgpack_rpc/channel.c @@ -33,6 +33,10 @@ #include "nvim/ui.h" #include "nvim/ui_client.h" +#ifdef MSWIN +# include "nvim/os/os_win_console.h" +#endif + #include "msgpack_rpc/channel.c.generated.h" #ifdef NVIM_LOG_DEBUG @@ -494,6 +498,13 @@ static void rpc_close_event(void **argv) channel_decref(channel); +#ifdef MSWIN + // For ":detach!": unexpected disconnect does not call ui_detach_channel, unlike the normal + // ":detach" case, so we need to do some cleanup here. + bool detached_ui_stdio = channel->streamtype == kChannelStreamStdio + && channel->detach && channel->rpc.ui != NULL; +#endif + // No more I/O can happen on this channel. Remove UI if there is one attached. // Do this here instead of in rpc_free() which isn't always called on exit, so that // UILeave events behave consistently. @@ -517,6 +528,12 @@ static void rpc_close_event(void **argv) } else if (channel->streamtype == kChannelStreamStdio && !channel->detach) { exit_on_closed_chan(0); } +#ifdef MSWIN + else if (detached_ui_stdio) { + // Move this server off the now-dead console so it keeps working (CONIN$/CONOUT$). + os_swap_to_hidden_console(); + } +#endif } void rpc_free(Channel *channel) diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index 263c78057a..4e7bc958c2 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -153,7 +153,8 @@ end) describe('TUI :detach', function() local child_server, screen - local function setup_detach_child() + local function setup_detach_child(opts) + opts = opts or {} n.clear() finally(function() n.check_close() @@ -171,7 +172,7 @@ describe('TUI :detach', function() 'colorscheme vim', '--cmd', nvim_set .. ' laststatus=2 background=dark', - }, { env = env_notermguicolors }) + }, { env = env_notermguicolors, cols = opts.cols }) tt.override_screen_expect_for_conpty(screen) end @@ -196,10 +197,6 @@ describe('TUI :detach', function() assert(status) eq(1, #child_uis) - eq( - { false, { 0, 'Vim(detach):E477: No ! allowed: detach!' } }, - { child_session:request('nvim_command', 'detach!') } - ) eq( { false, { 0, 'Vim(detach):E16: Invalid range' } }, { child_session:request('nvim_command', '2detach') } @@ -243,6 +240,41 @@ describe('TUI :detach', function() ]]) end) + it('detach! keeps the UI attached but survives client disconnect', function() + -- Wide enough that the confirmation message doesn't trigger a |hit-enter| prompt. + setup_detach_child({ cols = 80 }) + -- Capture the client (foreground TUI) job so we can kill it below. + local term_job = n.api.nvim_buf_get_var(0, 'terminal_job_id') + + -- Wait for the child server to come up (its socket to exist), then connect. + local child_session ---@type test.Session + retry(nil, nil, function() + child_session = n.connect(child_server) + end) + finally(function() + -- Stop the (surviving) server so it doesn't dangle. + pcall(function() + n.connect(child_server):request('nvim_command', 'qall!') + end) + end) + + feed_data('\027\027:detach!\013') + screen:expect({ any = vim.pesc('Nvim will continue running') }) + + -- Unlike ":detach", the UI is still attached. + eq(1, #({ child_session:request('nvim_list_uis') })[2]) + + -- Simulate the host terminal disconnecting: SIGKILL the client so it can't shut down cleanly. + -- On Windows this also exercises the console hand-off in rpc_close_event(). + n.exec_lua(function(pid) + vim.uv.kill(pid, 'sigkill') + end, n.fn.jobpid(term_job)) + + retry(nil, 4000, function() + eq(2, ({ child_session:request('nvim_eval', '1+1') })[2]) + end) + end) + it('% detaches other UIs', function() setup_detach_child() finally(function() @@ -479,9 +511,9 @@ describe('TUI :restart', function() tt.feed_data('ifoo\027') tt.feed_data('ZR') screen:expect({ any = 'E37:' }) - -- Dismiss hit-enter so the next "E37" assertion below doesn't match this one immediately. - tt.feed_data('\013') - screen:expect({ any = vim.pesc('[No Name]') }) + -- Overwrite the message so the next "E37" is a real transition, not a stale immediate match. + tt.feed_data(':echo "cleared"\r') + screen:expect({ any = 'cleared', none = 'E37:' }) tt.feed_data('1ZR') screen:expect({ any = 'E37:' })