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.
This commit is contained in:
Justin M. Keyes
2026-08-04 06:20:16 -04:00
committed by GitHub
parent 0abc9ccc19
commit 8589447159
7 changed files with 94 additions and 28 deletions

View File

@@ -12,6 +12,7 @@
},
"diagnostics": {
"disable": [
"preferred-local-alias",
"unnecessary-if"
]
},

View File

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

View File

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

View File

@@ -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',
},

View File

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

View File

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

View File

@@ -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:' })