feat(startup): --embed --listen waits for UI if stdin is not a pipe

Problem:
`nvim --embed --listen addr` always treats stdin as an RPC channel. If
stdin is /dev/null (e.g. backgrounded over ssh), the channel closes
before a UI can attach, so Nvim exits (detach=false behavior). If stdin
is a tty, keystrokes are read as RPC.

Solution:
With `--embed --listen`, open the stdio RPC channel only if stdin is
a pipe or socket. Else wait for a UI at the `--listen` address.

    $ nvim --embed --listen ./nvim.sock
    Nvim server (pid: 71984) waiting for UI, listening on: "./nvim.sock"

Note:
- No change when stdin is a pipe. So the test harness, TUI, and :restart
  all behave exactly as before.
- The #29634 use-case of configuring a UI that attaches *after* startup
  (a second UI, or reattach) still requires #25377 (reliable UIEnter).
  Not affected by this change.
This commit is contained in:
Justin M. Keyes committed 2026-10-09 16:57:44 +02:00
1 parent c60f1c635d
commit d212676f90
5 files changed
+51 -12

No files matched your search

+2 -1
View File
@@ -489,7 +489,8 @@ PLUGINS
STARTUP
• todo
• |--embed| with |--listen| waits for a UI on the listen address if stdin is
not a pipe (e.g. tty or /dev/null), instead of exiting.
TERMINAL
+14 -5
View File
@@ -383,10 +383,10 @@ argument.
channel is still open.
Waits for a client to call |nvim_ui_attach()| before sourcing
startup files and reading buffers, so that UIs can handle
(display) early messages, dialogs, etc. Clients can do other
requests before `nvim_ui_attach` (e.g. `nvim_get_api_info` for
feature-detection). During this pre-startup phase the user
startup files and reading buffers, so UIs can display early
messages/dialogs and make preliminary requests (e.g.
`nvim_chan_set` to set channel config, or `nvim_get_api_info`
for feature-detection). During this pre-startup phase the user
config is of course not available (similar to `--cmd`).
Non-UI embedders must pass |--headless|, then startup will
@@ -398,8 +398,17 @@ argument.
UI embedders that want the UI protocol on a socket (instead of
stdio) must pass |--listen| as well as |--embed|: >
nvim --embed --listen addr
< Then stdin is an RPC channel only if it is a pipe/socket.
Otherwise (tty, /dev/null) Nvim waits for a UI to attach at
`addr`, e.g. to start a server for a GUI on a remote host: >
ssh host 'nvim --embed --listen 0.0.0.0:8888 &'
< The UI can call |nvim_chan_set()| with `detach=false` to
enable exit-on-channel-close behavior.
< See also: |ui-startup| |channel-stdio|
To "daemonize" such a server, use |jobstart()| with "detach": >
nvim -es +"lua vim.fn.jobstart({'nvim', '--embed', '--listen', 'addr'}, {detach=true, stdin='null'})" +q
<
See also: |ui-startup| |channel-stdio|
*--headless*
--headless Start without UI, and do not wait for `nvim_ui_attach`. The
+10 -3
View File
@@ -309,7 +309,11 @@ int main(int argc, char **argv)
}
#endif
if (embedded_mode) {
// "--embed --listen …": use stdio as RPC chan only if stdin is a pipe/socket.
// Otherwise (tty, /dev/null, EOF) wait for a UI.
uv_handle_type stdin_type = uv_guess_handle(STDIN_FILENO);
bool stdio_rpc = !params.listen_addr || stdin_type == UV_NAMED_PIPE || stdin_type == UV_TCP;
if (embedded_mode && stdio_rpc) {
const char *err;
if (!channel_from_stdio(true, CALLBACK_READER_INIT, &err)) {
abort();
@@ -418,10 +422,13 @@ int main(int argc, char **argv)
// Set the break level after the terminal is initialized.
debug_break_level = params.use_debug_break_level;
// Wait for UIs to set up Nvim or show early messages
// and prompts (--cmd, swapfile dialog, …).
// Wait for UIs to set up Nvim or show early messages and prompts (--cmd, swapfile dialog, …).
bool use_remote_ui = (embedded_mode && !headless_mode);
if (use_remote_ui) {
if (!stdio_rpc) {
fprintf(stderr, "Nvim server (pid: %" PRId64 ") waiting for UI, listening on: \"%s\"\n",
os_get_pid(), get_vim_var_str(VV_SEND_SERVER));
}
TIME_MSG("waiting for UI");
remote_ui_wait_for_attach();
TIME_MSG("done waiting for UI");
+1 -1
View File
@@ -551,7 +551,7 @@ describe('TUI :restart', function()
-- [9]ZR discards unsaved changes.
tt.feed_data('9ZR')
screen:expect({ any = vim.pesc('[No Name]') })
screen:expect({ any = vim.pesc('[No Name]'), none = vim.pesc('[+]') })
starttime, server_session = assert_restarted(starttime, server_session, server_pipe)
-- Restarted server self-exits when the TUI disconnects / host-terminal closed. #42250
+24 -2
View File
@@ -1,6 +1,7 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local Screen = require('test.functional.ui.screen')
local tt = require('test.functional.testterm')
local describe, it, after_each, pending, finally =
t.describe, t.it, t.after_each, t.pending, t.finally
local uv = vim.uv
@@ -299,8 +300,8 @@ describe('--embed UI', function()
end)
end)
describe('--embed --listen UI', function()
it('waits for connection on listening address', function()
describe('--embed --listen', function()
it('waits for UI on listening address', function()
clear()
local child_server = assert(n.new_pipename())
fn.jobstart({
@@ -357,4 +358,25 @@ describe('--embed --listen UI', function()
ok(var_ok)
eq({ 'VimEnter', ('UIEnter:%d'):format(api_info[1]) }, var)
end)
it('waits for UI if stdin is not a pipe/socket', function()
clear()
local child_server = assert(n.new_pipename())
-- In :terminal, stdin is a tty.
local screen = tt.setup_child_nvim(
{ '--embed', '--listen', child_server, '--clean' },
{ cols = 256 } -- Wide screen so the message doesn't wrap.
)
screen:expect({ any = ('waiting for UI, listening on: "%s"'):format(vim.pesc(child_server)) })
local child_session = n.connect(child_server)
eq({ true, 0 }, { child_session:request('nvim_get_vvar', 'vim_did_enter') })
local child_screen = Screen.new(40, 6, nil, child_session)
child_screen:expect({ any = vim.pesc('[No Name]') })
-- The UI can claim the server: Nvim self-exits when the UI disconnects.
child_session:request('nvim_chan_set', 0, { detach = false })
child_session:close()
screen:expect({ any = 'Process exited' })
end)
end)