feat(remote-ssh): supervise server in Lua, "Update Nvim" for nightly #42706

Problem:
- remote_ssh.sh detects ssh disconnect by writing a heartbeat every 1s,
  which the local side buffers for the whole session.
- The server's stdout (e.g. `io.write()` from a random plugin) writes to
  the tunnel, which in theory may contain a bogus "NVIM_READY".
- A nightly install is never updated on the remote.

Solution:
- Supervise the server in `vim.net._ssh.serve()` (Lua). It detects
  disconnect via stdin EOF and discards server stdout. The script only
  finds or installs `nvim`.
  - Also a step toward Windows remotes.
- User may choose "Update Nvim" to reinstall the nightly.
This commit is contained in:
Justin M. Keyes authored and GitHub committed 2026-11-05 09:48:04 -05:00
1 parent 67f6f0503c
commit 893d90fced
7 files changed
+479 -335

No files matched your search

+3 -1
View File
@@ -160,7 +160,9 @@ Connect UI to a different server
- Config: `~/.config/nvim-remote/`
- Data: `~/.local/share/nvim-remote/`, kept across sessions
for fast reconnects. Caches installed releases, e.g.
`releases/v0.12.0-linux-x86_64/`.
`releases/v0.12.0-linux-x86_64/`. Nightly is installed once;
choose "Update Nvim" at the prompt to refresh it (may break
other sessions using it).
Requirements:
- Local: `ssh` (OpenSSH 7.6+). Win32-OpenSSH lacks
+3 -5
View File
@@ -876,11 +876,9 @@ local function check_sysinfo()
vim.wo[win].winbar =
'%#WarningMsg#%@v:lua.nvim_health_bugreport_open@▶ Create Bug Report on GitHub%X%*'
vim.api.nvim_create_autocmd('BufDelete', {
buf = buf,
once = true,
command = 'lua _G.nvim_health_bugreport_open = nil',
})
nvim_on('BufDelete', nil, { buf = buf, once = true }, function()
_G.nvim_health_bugreport_open = nil
end)
end)
end
+108 -15
View File
@@ -1,6 +1,8 @@
-- Converted into Lua from https://github.com/cyjake/ssh-config
-- TODO (siddhantdev): deal with include directives
local nvim_on = require('vim._core.util').nvim_on
local M = {}
local whitespace_pattern = '%s'
@@ -382,7 +384,8 @@ local function get_ssh_cmd(ssh_uri, opts)
end
--- Runs the local `$VIMRUNTIME/scripts/remote_ssh.sh` on the remote host. The script goes to
--- `sh -s` over stdin, so the remote login shell (any shell) parses only `args`.
--- `sh -s` over stdin, so the remote login shell (any shell) parses only `args`. Stdin stays open:
--- its EOF tells the remote (`M.serve()`) that ssh disconnected.
---
---@param uri vim.Uri & {host:string}
---@param args string[] Script args: plain words, which no shell expands, so they need no quoting.
@@ -399,7 +402,11 @@ local function run_script(uri, args, opts, on_exit)
local remote_cmd = vim.list_extend({ 'sh', '-s', '--' }, args)
local ssh_cmd = get_ssh_cmd(uri, { ssh_args = opts.ssh_args, remote_cmd = remote_cmd })
log().debug('run_script: running', ssh_cmd)
return vim.system(ssh_cmd, { stdin = script }, on_exit)
local obj = vim.system(ssh_cmd, { stdin = true }, on_exit)
-- Write the script to stdin, `sh -s -` executes it.
-- Stdin stays open and the remote Nvim inherits it (see remote_ssh.sh).
obj:write(script)
return obj
end
--- Gets the operating system and architecture from the remote system.
@@ -454,7 +461,7 @@ end
local function get_release(os, arch)
local ver = vim.version()
local base = ('v%d.%d.%d'):format(ver.major, ver.minor, ver.patch)
-- prereleases: install the current nightly, once per version
-- Prerelease: installs the current nightly once per version, until "Update" (see `M.connect()`).
local tag = ver.prerelease and 'nightly' or base
local install_name = ver.prerelease and ('nightly-%s'):format(base) or base
@@ -496,7 +503,8 @@ end
--- See `M.connect()`.
---@param chan? integer
---@param report fun(status: 'running'|'success'|'failed', msg: string)
local function connect(uri_str, chan, report)
---@param update boolean Re-installs the release.
local function connect(uri_str, chan, report, update)
local uri = vim.uri.parse(uri_str)
if not (uri and uri.host and uri.host ~= '') then
error(('No host in SSH URI: %s'):format(uri_str), 0)
@@ -556,12 +564,11 @@ local function connect(uri_str, chan, report)
report('running', 'Starting remote Nvim...')
log().debug('connect: local_sock', local_sock)
tunnel = run_script(
uri,
{ remote_sock, dir, url, version },
{ ssh_args = { '-L', forward } },
on_exit
)
local args = { remote_sock, dir, url, version }
if update then
table.insert(args, 1, '--update')
end
tunnel = run_script(uri, args, { ssh_args = { '-L', forward } }, on_exit)
local res = tunnel:wait(300000, function(stdout)
return stdout:match('NVIM_READY') ~= nil
end)
@@ -593,9 +600,7 @@ local function connect(uri_str, chan, report)
end
report('success', ('Connected to %s'):format(uri_str))
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = teardown,
})
nvim_on('VimLeavePre', nil, teardown)
return local_sock, teardown
end
@@ -612,11 +617,16 @@ function M.connect(uri_str, chan)
uri_str,
remote_data_dir
)
if vim.fn.confirm(msg, 'Connect? &Yes\n&No', 1, 'Question') ~= 1 then
local prerelease = vim.version().prerelease ~= nil
local choices = prerelease and 'Connect? &Yes\n&Update Nvim\n&No' -- Nightly is refreshed if "(U)pdate".
or 'Connect? &Yes\n&No'
local choice = vim.fn.confirm(msg, choices, 1, 'Question')
local update = prerelease and choice == 2
if choice ~= 1 and not update then
return nil
end
local report = new_progress_report()
local ok, local_sock, teardown = pcall(connect, uri_str, chan, report)
local ok, local_sock, teardown = pcall(connect, uri_str, chan, report, update)
if not ok then
report('failed', ('Failed to connect to %s'):format(uri_str))
error(local_sock, 0)
@@ -624,4 +634,87 @@ function M.connect(uri_str, chan)
return local_sock, teardown
end
--- Gets the PID of the server at `sock`, or nil if none answers.
---@param sock string
---@return integer?
local function server_pid(sock)
local ok, chan = pcall(vim.fn.sockconnect, 'pipe', sock, { rpc = true, trust = false })
if not ok or chan == 0 then
return nil
end
---@cast chan integer
local _, pid = pcall(vim.rpcrequest, chan, { timeout = 5000 }, 'nvim_call_function', 'getpid', {})
pcall(vim.fn.chanclose, chan)
return type(pid) == 'number' and pid --[[@as integer]] or nil
end
--- Runs on the remote host: starts the server at `sock` and supervises it, following a `:restart`ed
--- server (which re-binds `sock`).
---
--- PROTOCOL: `remote_ssh.sh` of other (older) Nvim versions calls this, so keep it compatible, or
--- extend it with an `opts` param (e.g. a protocol version):
--- - Called as `nvim … +"lua require('vim.net._ssh').serve(sock)" +qa!`, with stdin from ssh.
--- - Creates the dir of `sock`, which must not exist (for security), with mode 0700.
--- - Prints "NVIM_READY" once the server listens, or "NVIM_CRASHED".
--- - Returns when stdin closes (ssh disconnect) or the server disappears.
--- - On exit (also SIGTERM, SIGHUP): stops the server (SIGTERM) and removes the dir.
---@param sock string
function M.serve(sock)
local dir = vim.fs.dirname(sock)
-- Fresh private dir (0700): fails if it exists, so nobody can plant a socket.
assert(vim.uv.fs_mkdir(dir, 448))
-- Not a peer (`serverlist()`) of the server.
vim.fn.serverstop(vim.v.servername)
-- No pty, so no SIGHUP when ssh disconnects: stdin EOF instead (see `run_script()`).
local disconnected = false
local stdin = assert(vim.uv.new_pipe())
stdin:open(0)
stdin:read_start(function(_, data)
disconnected = disconnected or data == nil
end)
local pid --- @type integer? Current server.
-- Cleanup: stops the server and removes `dir` when Nvim exits (SIGTERM/SIGHUP).
nvim_on('VimLeavePre', nil, function()
local cur = pid and (server_pid(sock) or pid)
if cur then
-- SIGTERM (unlike `:qall!`) preserves swap files.
vim.uv.kill(cur, 'sigterm')
vim.wait(5000, function()
return vim.uv.kill(cur, 0) ~= 0
end, 50)
end
vim.fs.rm(dir, { recursive = true, force = true })
stdin:close() -- Release stdin handle, else EXITFREE build will complain.
end)
vim.env.__NVIM_STARTREASON = 'remote-ssh'
vim.env.NVIM_APPNAME = 'nvim-remote'
local server = vim.system({ vim.v.progpath, '--headless', '--listen', sock }, {
stdout = false,
stderr = function(_, data)
io.stderr:write(data or '')
end,
})
vim.wait(vim._maxint, function()
return disconnected or server:is_closing() or vim.uv.fs_stat(sock) ~= nil
end, 50)
io.stdout:write(server:is_closing() and 'NVIM_CRASHED\n' or 'NVIM_READY\n')
io.stdout:flush()
pid = server.pid
-- Supervises the server (polls every 1s) at `sock` until ssh disconnects, including a new server after `:restart`.
while pid and not disconnected do
local cur = pid
vim.wait(vim._maxint, function()
return disconnected or vim.uv.kill(cur, 0) ~= 0
end, 1000)
if not disconnected then
-- The server exited. A `:restart`ed server re-binds `sock`.
pid = server_pid(sock)
end
end
end
return M
+20 -43
View File
@@ -1,11 +1,11 @@
#!/bin/sh
# Runs the headless Nvim server of a |remote-ssh| session, until it exits or ssh disconnects.
# Prints NVIM_READY once the server listens (or NVIM_CRASHED).
# Bootstraps the headless Nvim server of a |remote-ssh| session: finds or installs `nvim`, which then
# runs and supervises the server (vim.net._ssh.serve()).
#
# Usage: remote_ssh.sh {sock} {dir} {url} [{version}]
# Usage: remote_ssh.sh [--update] {sock} {dir} {url} [{version}]
# Runs `nvim` from $PATH if it is {version} or newer, else the release {url}, which it installs
# to {dir} (relative to $HOME) unless present. E.g.
# to {dir} (relative to $HOME) unless present, or always with --update. E.g.
# remote_ssh.sh /tmp/nvim-remote.0123abcd/nvim.sock .local/share/nvim-remote/releases/v0.12.0-linux-x86_64 https://…/nvim-linux-x86_64.tar.gz v0.12.0
set -eu
@@ -28,55 +28,32 @@ system_nvim() {
echo "$nvim"
}
# Installs the release to $dest, unless present.
# Installs the release to $dest, unless present (and not $update).
install_release() (
if [ -x "$dest/bin/nvim" ]; then
if [ -x "$dest/bin/nvim" ] && [ -z "$update" ]; then
exit 0
fi
echo "Installing $dest..." >&2
mkdir -p "${dest%/*}"
tmp=$(mktemp -d "$dest.XXXXXX")
trap 'rm -rf "$tmp"' EXIT
curl -fsSL "$url" | tar -xzf - -C "$tmp"
mkdir "$tmp/new"
curl -fsSL "$url" | tar -xzf - -C "$tmp/new"
if [ -n "$update" ]; then
# Removed with $tmp. Gone already if a concurrent update moved it.
mv "$dest" "$tmp/old" 2>/dev/null || :
fi
# The archive holds one top-level dir, e.g. `nvim-linux-x86_64/`. Atomic rename; keeps a
# concurrent install that finished first.
[ -e "$dest" ] || mv "$tmp"/* "$dest"
[ -e "$dest" ] || mv "$tmp"/new/* "$dest"
)
# Starts `nvim` ($1) as the server at $sock, in a private dir. Both are removed on exit.
# Prints NVIM_READY once it listens, or NVIM_CRASHED.
start_server() {
dir=${sock%/*}
# Fresh private dir: `mkdir` fails if it exists, so nobody can plant a socket.
mkdir -m 700 "$dir"
nvim_pid=
trap 'kill "$nvim_pid" 2>/dev/null || true; rm -rf "$dir"' EXIT
trap 'exit 1' HUP INT TERM PIPE
__NVIM_STARTREASON=remote-ssh NVIM_APPNAME=nvim-remote "$1" --headless --listen "$sock" </dev/null &
nvim_pid=$!
while [ ! -S "$sock" ]; do
if ! kill -0 "$nvim_pid" 2>/dev/null; then
echo "NVIM_CRASHED"
exit 1
fi
sleep 0.1
done
echo "NVIM_READY"
}
# Waits until the server exits or ssh disconnects. No pty, so no SIGHUP on disconnect: a failed
# heartbeat write triggers the EXIT trap.
heartbeat() {
while kill -0 "$nvim_pid" 2>/dev/null; do
# `wait` (unlike a foreground `sleep`) lets a signal run its trap at once. The `sleep` must not
# hold stdout, else the closing ssh channel waits for it.
sleep 1 >/dev/null 2>&1 &
wait $!
printf '\n' 2>/dev/null || exit 1
done
}
main() {
update=
if [ "$1" = --update ]; then
update=1
shift
fi
sock=$1
dest="$HOME/$2"
url=$3
@@ -85,8 +62,8 @@ main() {
install_release
nvim="$dest/bin/nvim"
fi
start_server "$nvim"
heartbeat
# Nvim inherits stdin from `sh -s` (i.e. from ssh), so EOF tells serve() that ssh disconnected.
exec "$nvim" --clean -n --headless +"lua require('vim.net._ssh').serve('$sock')" +qa!
}
main "$@"
+2 -1
View File
@@ -4896,7 +4896,8 @@ static void ex_exmode(exarg_T *eap)
/// ":restart +cmd <command>": restart the Nvim server using ":cmd" and runs <command> in the new server.
static void ex_restart(exarg_T *eap)
{
// XXX: The UI reaches this server through an SSH tunnel, which cannot reach the new server.
// XXX: The UI reaches this server through an SSH tunnel, but the "restart" event gives it the new
// server's (remote) address.
if (strequal(get_vim_var_str(VV_STARTREASON), "remote-ssh")) {
emsg(_("E6103: :restart is not supported with remote-ssh"));
return;
+10 -2
View File
@@ -33,10 +33,18 @@ elseif not args:find(' -L ', 1, true) then
fail(('Unexpected SSH command: %s'):format(args))
end
args = ('%s %s'):format(args, io.stdin:read('*a'))
-- The script on stdin, which stays open (see `run_script()`).
local script = {} --- @type string[]
for line in io.stdin:lines() do
script[#script + 1] = line
if line == 'main "$@"' then
break
end
end
args = ('%s %s'):format(args, table.concat(script, '\n'))
if not args:find('ControlMaster', 1, true) then
fail('FAIL: Multiplexing flags missing!')
elseif not args:find(' sh %-s .*NVIM_READY') then
elseif not args:find(' sh %-s .*main "%$@"') then
fail('FAIL: tunnel script not fed to sh -s')
end
write('tunnel.args', args)
+333 -268
View File
@@ -61,8 +61,8 @@ describe('parse_ssh_config()', function()
end)
it('ignores repeated and trailing separators', function()
-- Runs of separators between values, and trailing whitespace before the
-- line break, must not yield empty hostnames.
-- Runs of separators between values, and trailing whitespace before the line break, must not
-- yield empty aliases.
local config = table.concat({
'Host alpha beta ',
' HostName example.com',
@@ -106,7 +106,7 @@ describe('parse_ssh_config()', function()
end)
end)
describe('vim.net._ssh', function()
describe('remote-ssh', function()
local fake_dir --- @type string
--- Makes `vim.net._ssh` run a fake `ssh` (fixtures/fake-ssh.lua), and stubs `confirm()`.
@@ -156,292 +156,357 @@ describe('vim.net._ssh', function()
clear()
end)
describe('remote-ssh (Introspection)', function()
it('detects linux x86_64', function()
setup_fake_ssh({
uname = 'Linux\nx86_64\n',
})
local res = n.exec_lua([[
return { require('vim.net._ssh').get_system_info({ host = 'server' }) }
]])
eq('linux', res[1])
eq('x86_64', res[2])
end)
it('get_system_info() detects OS and arch', function()
for _, c in ipairs({
{ 'Linux\nx86_64\n', { 'linux', 'x86_64' } },
{ 'Darwin\narm64\n', { 'darwin', 'arm64' } },
}) do
setup_fake_ssh({ uname = c[1] })
eq(
c[2],
n.exec_lua(function()
return { require('vim.net._ssh').get_system_info({ host = 'server' }) }
end)
)
end
it('detects macos arm64', function()
setup_fake_ssh({
uname = 'Darwin\narm64\n',
})
local res = n.exec_lua([[
return { require('vim.net._ssh').get_system_info({ host = 'server' }) }
]])
eq('darwin', res[1])
eq('arm64', res[2])
end)
it('fails fast on Windows', function()
setup_fake_ssh({
uname = 'MSYS_NT-10.0-19045\nx86_64\n',
})
local status, err = pcall(function()
n.exec_lua([[
require('vim.net._ssh').get_system_info({ host = 'server' })
]])
setup_fake_ssh({ uname = 'MSYS_NT-10.0-19045\nx86_64\n' })
t.matches(
'Windows targets are not supported',
t.pcall_err(n.exec_lua, function()
require('vim.net._ssh').get_system_info({ host = 'server' })
end)
eq(false, status)
assert(string.match(err, 'Windows targets are not supported'))
end)
)
end)
describe('remote-ssh (Orchestration)', function()
it('returns local socket with key-based auth', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local sock, logfile, progress = unpack(n.exec_lua([[
local logfile = vim.fs.joinpath(vim.fn.stdpath('log'), 'remote-ssh.log')
os.remove(logfile)
local progress = {}
vim.api.nvim_create_autocmd('Progress', {
pattern = 'remote-ssh',
callback = function(ev)
table.insert(progress, ('%s: %s'):format(ev.data.status, table.concat(ev.data.text)))
end,
})
local sock, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
return { sock, logfile, progress }
]]))
assert(sock:match('_remote_nvim%.sock$'))
eq({
'running: Connecting to ssh://user@test-server...',
'running: Starting remote Nvim...',
'success: Connected to ssh://user@test-server',
}, progress)
local msg = 'This will install Nvim on ssh://user@test-server (in ~/.local/share/nvim-remote/), '
.. 'if missing.\n'
eq({ { msg, 'Connect? &Yes\n&No', 1, 'Question' } }, n.exec_lua('return _G.confirm_log'))
t.matches('Connected to ssh://user@test%-server', t.read_file(logfile))
local tunnel_args = t.read_file(fake_dir .. '/tunnel.args')
t.matches('BatchMode=yes', tunnel_args)
t.matches('RemoteCommand=none', tunnel_args)
t.matches('StrictHostKeyChecking=accept%-new', tunnel_args)
t.matches('ServerAliveInterval=15', tunnel_args)
-- Teardown stops the tunnel and cancels its forward, but not the local server.
eq(true, tunnel_exited())
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
n.poke_eventloop()
n.assert_alive()
-- Explicit ssh_config values are kept. The target is decoded, with an IPv6 host unbracketed.
setup_fake_ssh({ ssh_config = 'stricthostkeychecking yes\nserveraliveinterval 60\n' })
n.exec_lua(function()
local _, teardown = require('vim.net._ssh').connect('ssh://a%40b@[::1]:2222/x')
teardown()
end)
local explicit_args = t.read_file(fake_dir .. '/tunnel.args')
t.matches(' %-p 2222 %-%- a@b@::1 sh ', explicit_args)
eq(nil, explicit_args:find('StrictHostKeyChecking', 1, true))
eq(nil, explicit_args:find('ServerAliveInterval', 1, true))
-- Stops the session if the UI detached meanwhile.
setup_fake_ssh()
t.matches(
'UI detached: channel 999',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server', 999)]])
)
eq(true, tunnel_exited())
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
it('stops the local server when the session ends, unless it is in use', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local logfile =
n.exec_lua([[return vim.fs.joinpath(vim.fn.stdpath('log'), 'remote-ssh.log')]])
it('connect() returns the local socket of the tunnel', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local sock, logfile, progress = unpack(n.exec_lua([[
local logfile = vim.fs.joinpath(vim.fn.stdpath('log'), 'remote-ssh.log')
os.remove(logfile)
-- Connects, then ends the session as the remote does: its Nvim quits.
local session = [[
local sock = require('vim.net._ssh').connect('ssh://user@test-server')
vim.rpcnotify(vim.fn.sockconnect('pipe', sock, { rpc = true }), 'nvim_command', 'qall!')
]]
local progress = {}
vim.api.nvim_create_autocmd('Progress', {
pattern = 'remote-ssh',
callback = function(ev)
table.insert(progress, ('%s: %s'):format(ev.data.status, table.concat(ev.data.text)))
end,
})
local sock, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
return { sock, logfile, progress }
]]))
n.command('set modified')
n.exec_lua(session)
t.retry(nil, 5000, function()
t.matches('local server in use, keeping it', t.read_file(logfile))
end)
n.assert_alive()
assert(sock:match('_remote_nvim%.sock$'))
eq({
'running: Connecting to ssh://user@test-server...',
'running: Starting remote Nvim...',
'success: Connected to ssh://user@test-server',
}, progress)
local msg = 'This will install Nvim on ssh://user@test-server (in ~/.local/share/nvim-remote/), '
.. 'if missing.\n'
local choices = n.exec_lua('return vim.version().prerelease')
and 'Connect? &Yes\n&Update Nvim\n&No'
or 'Connect? &Yes\n&No'
eq({ { msg, choices, 1, 'Question' } }, n.exec_lua('return _G.confirm_log'))
t.matches('Connected to ssh://user@test%-server', t.read_file(logfile))
local tunnel_args = t.read_file(fake_dir .. '/tunnel.args')
t.matches('BatchMode=yes', tunnel_args)
t.matches('RemoteCommand=none', tunnel_args)
t.matches('StrictHostKeyChecking=accept%-new', tunnel_args)
t.matches('ServerAliveInterval=15', tunnel_args)
-- Teardown stops the tunnel and cancels its forward, but not the local server.
eq(true, tunnel_exited())
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
n.poke_eventloop()
n.assert_alive()
n.command('set nomodified')
n.expect_exit(5000, n.exec_lua, session)
-- Explicit ssh_config values are kept. The target is decoded, with an IPv6 host unbracketed.
setup_fake_ssh({ ssh_config = 'stricthostkeychecking yes\nserveraliveinterval 60\n' })
n.exec_lua(function()
local _, teardown = require('vim.net._ssh').connect('ssh://a%40b@[::1]:2222/x')
teardown()
end)
local explicit_args = t.read_file(fake_dir .. '/tunnel.args')
t.matches(' %-p 2222 %-%- a@b@::1 sh ', explicit_args)
eq(nil, explicit_args:find('StrictHostKeyChecking', 1, true))
eq(nil, explicit_args:find('ServerAliveInterval', 1, true))
eq(
'No host in SSH URI: ssh:///x',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh:///x')]])
)
it('passes the matching release to the remote script', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local base, prerelease = unpack(n.exec_lua(function()
-- Stops the session if the UI detached meanwhile.
setup_fake_ssh()
t.matches(
'UI detached: channel 999',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server', 999)]])
)
eq(true, tunnel_exited())
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
it('stops the local server when the session ends, unless it is in use', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local logfile = n.exec_lua([[return vim.fs.joinpath(vim.fn.stdpath('log'), 'remote-ssh.log')]])
os.remove(logfile)
-- Connects, then ends the session as the remote does: its Nvim quits.
local session = [[
local sock = require('vim.net._ssh').connect('ssh://user@test-server')
vim.rpcnotify(vim.fn.sockconnect('pipe', sock, { rpc = true }), 'nvim_command', 'qall!')
]]
n.command('set modified')
n.exec_lua(session)
t.retry(nil, 5000, function()
t.matches('local server in use, keeping it', t.read_file(logfile))
end)
n.assert_alive()
n.command('set nomodified')
n.expect_exit(5000, n.exec_lua, session)
end)
it('passes the matching release to the remote script', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local base, prerelease = unpack(n.exec_lua(function()
local _, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
local v = vim.version()
return { ('v%d.%d.%d'):format(v.major, v.minor, v.patch), v.prerelease and true or false }
end))
local url = 'https://github.com/neovim/neovim/releases/download/%s/nvim-linux-x86_64.tar.gz'
local dir = '.local/share/nvim-remote/releases/%s-linux-x86_64'
-- A prerelease installs the nightly, and passes no version, so a system `nvim` is never used.
local args = prerelease and { dir:format('nightly-' .. base), url:format('nightly') }
or { dir:format(base), url:format(base), base }
eq(
table.concat(args, ' '),
t.read_file(fake_dir .. '/tunnel.args')
:match(' sh %-s %-%- /tmp/nvim%-remote%.%x+/nvim%.sock (.-) #!/bin/sh')
)
-- "Update Nvim" (prerelease only) re-installs the nightly.
if prerelease then
setup_fake_ssh({ confirm = 2 })
n.exec_lua(function()
local _, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
local v = vim.version()
return { ('v%d.%d.%d'):format(v.major, v.minor, v.patch), v.prerelease and true or false }
end))
local url = 'https://github.com/neovim/neovim/releases/download/%s/nvim-linux-x86_64.tar.gz'
local dir = '.local/share/nvim-remote/releases/%s-linux-x86_64'
-- A prerelease installs the current nightly, and passes no version for a system `nvim`.
local args = prerelease and { dir:format('nightly-' .. base), url:format('nightly') }
or { dir:format(base), url:format(base), base }
eq(
table.concat(args, ' '),
t.read_file(fake_dir .. '/tunnel.args')
:match(' sh %-s %-%- /tmp/nvim%-remote%.%x+/nvim%.sock (.-) #!/bin/sh')
)
end)
it('remote script runs `nvim` from $PATH if new enough, else installs the release', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
local home = vim.fs.abspath(t.tmpname(false))
t.finally(function()
n.rmdir(home)
end)
local nvim_prog = vim.fs.abspath(n.nvim_prog)
n.mkdir_p(home .. '/bin')
vim.uv.fs_symlink(nvim_prog, home .. '/bin/nvim')
-- Release archive: its `nvim` marks the server it runs.
local release = home .. '/src/nvim-test'
n.mkdir_p(release .. '/bin')
t.write_file(
release .. '/bin/nvim',
('#!/bin/sh\nexport NVIM_TEST_RELEASE=1\nexec %s "$@"\n'):format(nvim_prog)
)
vim.uv.fs_chmod(release .. '/bin/nvim', 493)
local tarball = home .. '/nvim-test.tar.gz'
eq(0, vim.system({ 'tar', '-czf', tarball, '-C', home .. '/src', 'nvim-test' }):wait().code)
local dir = 'releases/v1-test'
t.matches(' sh %-s %-%- %-%-update /tmp/', t.read_file(fake_dir .. '/tunnel.args'))
end
end)
--- Runs the script locally, as the remote does, and asks its server for `[$NVIM_APPNAME,
--- $NVIM_TEST_RELEASE, v:startreason]`.
local function serve(version)
return n.exec_lua(function(...)
local sock = ('%s/sock/nvim.sock'):format(home)
local cmd =
{ 'env', ('HOME=%s'):format(home), ('PATH=%s/bin:%s'):format(home, vim.env.PATH) }
vim.list_extend(cmd, { 'sh', 'runtime/scripts/remote_ssh.sh', sock, ... })
local obj = vim.system(cmd)
obj:wait(10000, function(stdout)
return stdout:match('NVIM_READY') ~= nil
end)
local ch = vim.fn.sockconnect('pipe', sock, { rpc = true })
local env =
vim.rpcrequest(ch, 'nvim_eval', '[$NVIM_APPNAME, $NVIM_TEST_RELEASE, v:startreason]')
vim.fn.chanclose(ch)
obj:kill('sigterm')
local res = obj:wait(5000)
-- The script removes its socket dir on exit.
local removed = vim.uv.fs_stat(vim.fs.dirname(sock)) == nil
return { env, removed, res.stderr:match('Installing %S+') }
end, dir, ('file://%s'):format(tarball), version)
end
eq({ { 'nvim-remote', '', 'remote-ssh' }, true }, serve('v0.1.0'))
eq(nil, vim.uv.fs_stat(('%s/%s'):format(home, dir)))
local installing = ('Installing %s/%s...'):format(home, dir)
eq({ { 'nvim-remote', '1', 'remote-ssh' }, true, installing }, serve('v999.0.0'))
-- No version (a prerelease): never uses $PATH; the install is kept.
eq({ { 'nvim-remote', '1', 'remote-ssh' }, true }, serve())
-- The UI could not reach a restarted server through the tunnel.
n.clear({ env = { __NVIM_STARTREASON = 'remote-ssh' } })
eq(
'Vim(restart):E6103: :restart is not supported with remote-ssh',
t.pcall_err(n.command, 'restart')
)
it('remote script runs acceptable `nvim` from $PATH, else installs the release', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
local home = vim.fs.abspath(t.tmpname(false))
t.finally(function()
n.rmdir(home)
end)
local nvim_prog = vim.fs.abspath(n.nvim_prog)
n.mkdir_p(home .. '/bin')
vim.uv.fs_symlink(nvim_prog, home .. '/bin/nvim')
-- Release archive: its `nvim` marks the server it runs.
local release = home .. '/src/nvim-test'
n.mkdir_p(release .. '/bin')
t.write_file(
release .. '/bin/nvim',
('#!/bin/sh\nexport NVIM_TEST_RELEASE=1\nexec %s "$@"\n'):format(nvim_prog)
)
vim.uv.fs_chmod(release .. '/bin/nvim', 493)
local tarball = home .. '/nvim-test.tar.gz'
eq(0, vim.system({ 'tar', '-czf', tarball, '-C', home .. '/src', 'nvim-test' }):wait().code)
local dir = 'releases/v1-test'
it('fails when ssh exits before the remote Nvim is ready', function()
setup_fake_ssh({
tunnel = { stderr = 'channel 0: open failed: administratively prohibited\n', exit = 255 },
})
--- Runs the script locally, as the remote does. Returns `[$NVIM_APPNAME, $NVIM_TEST_RELEASE,
--- v:startreason]` of its server, whether its socket dir was removed on exit, and its
--- `Installing …` message, if any.
local function serve(version, update)
return n.exec_lua(function(...)
local sock = ('%s/sock/nvim.sock'):format(home)
local cmd =
{ 'env', ('HOME=%s'):format(home), ('PATH=%s/bin:%s'):format(home, vim.env.PATH) }
vim.list_extend(cmd, { 'sh', 'runtime/scripts/remote_ssh.sh' })
vim.list_extend(cmd, update and { '--update', sock, ... } or { sock, ... })
local obj = vim.system(cmd, { stdin = true }) -- Use stdin to signal disconnect.
obj:wait(10000, function(stdout)
return stdout:match('NVIM_READY') ~= nil
end)
local ch = vim.fn.sockconnect('pipe', sock, { rpc = true })
local env =
vim.rpcrequest(ch, 'nvim_eval', '[$NVIM_APPNAME, $NVIM_TEST_RELEASE, v:startreason]')
vim.fn.chanclose(ch)
obj:write(nil) -- ssh disconnects (stdin closed).
local res = obj:wait(5000)
-- `vim.net._ssh.serve()` removes its socket dir on exit.
local removed = vim.uv.fs_stat(vim.fs.dirname(sock)) == nil
return { env, removed, res.stderr:match('Installing %S+') }
end, dir, ('file://%s'):format(tarball), version)
end
eq({ { 'nvim-remote', '', 'remote-ssh' }, true }, serve('v0.1.0'))
eq(nil, vim.uv.fs_stat(('%s/%s'):format(home, dir)))
local installing = ('Installing %s/%s...'):format(home, dir)
eq({ { 'nvim-remote', '1', 'remote-ssh' }, true, installing }, serve('v999.0.0'))
-- No version (a prerelease): never uses $PATH; reuses the install.
eq({ { 'nvim-remote', '1', 'remote-ssh' }, true }, serve())
-- --update replaces the install, and leaves no temp dirs.
t.write_file(('%s/%s/stale'):format(home, dir), '')
eq({ { 'nvim-remote', '1', 'remote-ssh' }, true, installing }, serve(nil, true))
eq({ 'bin' }, n.fn.readdir(('%s/%s'):format(home, dir)))
eq({ 'v1-test' }, n.fn.readdir(home .. '/releases'))
-- Follows a restarted server, which re-binds the socket. The socket dir is private.
eq(
{ tonumber('700', 8), true, true },
n.exec_lua(function()
vim.api.nvim_create_autocmd('Progress', {
pattern = 'remote-ssh',
callback = function(ev)
_G.last_progress = ('%s: %s'):format(ev.data.status, table.concat(ev.data.text))
end,
local sock = ('%s/sock/nvim.sock'):format(home)
local cmd =
{ 'env', ('HOME=%s'):format(home), ('PATH=%s/bin:%s'):format(home, vim.env.PATH) }
vim.list_extend(cmd, { 'sh', 'runtime/scripts/remote_ssh.sh', sock, dir, '', 'v0.1.0' })
local obj = vim.system(cmd, { stdin = true })
obj:wait(10000, function(stdout)
return stdout:match('NVIM_READY') ~= nil
end)
local mode = bit.band(assert(vim.uv.fs_stat(vim.fs.dirname(sock))).mode, tonumber('777', 8))
-- As `:restart`: the old server stops listening, a new one listens, the old one exits.
local old = vim.fn.sockconnect('pipe', sock, { rpc = true })
vim.rpcrequest(old, 'nvim_call_function', 'serverstop', { sock })
local count_chans = 'let g:chans = 0 | autocmd ChanOpen * let g:chans += 1'
local new = vim.system({
vim.v.progpath,
'--clean',
'--headless',
'--cmd',
count_chans,
'--listen',
sock,
})
vim.wait(5000, function()
return vim.uv.fs_stat(sock) ~= nil
end)
local ch = vim.fn.sockconnect('pipe', sock, { rpc = true })
vim.rpcnotify(old, 'nvim_command', 'qall!')
-- `vim.net._ssh.serve()` connects to the new server (to get its PID).
assert(vim.wait(5000, function()
return vim.rpcrequest(ch, 'nvim_eval', 'g:chans') > 1
end))
obj:write(nil) -- ssh disconnects: stops the new server.
return { mode, new:wait(5000).code ~= 124, obj:wait(5000).code == 0 }
end)
t.matches(
'SSH tunnel exited before the remote Nvim was ready: channel 0: open failed',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq(
'failed: Failed to connect to ssh://user@test-server',
n.exec_lua('return _G.last_progress')
)
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
)
it('fails when the remote Nvim crashes during startup, or is unreachable', function()
setup_fake_ssh({
tunnel = { stdout = 'NVIM_CRASHED\n', exit = 1 },
})
t.matches(
'Remote Nvim crashed during startup',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq(
'No host in SSH URI: ssh:///x',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh:///x')]])
)
-- The script runs, but sshd refused the forward.
setup_fake_ssh({
tunnel = { stdout = 'NVIM_READY\n' },
})
t.matches(
'Remote Nvim is not reachable through the SSH forward',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
it('serves each session from a fresh private remote directory', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local function remote_dir()
n.exec_lua([[
local _, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
]])
local args = t.read_file(fake_dir .. '/tunnel.args')
return args:match(':(/tmp/nvim%-remote%.%x+)/nvim%.sock ')
end
local dir1, dir2 = remote_dir(), remote_dir()
assert(dir1 and dir2, 'remote socket path not found')
assert(dir1 ~= dir2, 'remote socket directory reused across sessions')
end)
it(':connect ssh:// does nothing if it cannot attach, or is declined', function()
setup_fake_ssh({ confirm = 2 })
eq('Vim(connect):E477: No ! allowed', t.pcall_err(n.command, 'connect! ssh://test-server'))
eq('Vim(connect):E5768: No UI attached', t.pcall_err(n.command, 'connect ssh://test-server'))
-- Typed by a client that is not a UI.
n.command('let v:errmsg = ""')
local client = n.connect(n.eval('v:servername'))
client:request('nvim_input', ':connect ssh://test-server<CR>')
t.retry(nil, nil, function()
eq('E5768: No UI attached', n.eval('v:errmsg'))
-- Stops the server and removes the socket dir also on a signal.
eq(
{ true, true },
n.exec_lua(function()
local sock = ('%s/sock/nvim.sock'):format(home)
local cmd =
{ 'env', ('HOME=%s'):format(home), ('PATH=%s/bin:%s'):format(home, vim.env.PATH) }
vim.list_extend(cmd, { 'sh', 'runtime/scripts/remote_ssh.sh', sock, dir, '', 'v0.1.0' })
local obj = vim.system(cmd, { stdin = true })
obj:wait(10000, function(stdout)
return stdout:match('NVIM_READY') ~= nil
end)
local ch = vim.fn.sockconnect('pipe', sock, { rpc = true })
local server = vim.rpcrequest(ch, 'nvim_call_function', 'getpid', {})
vim.uv.kill(obj.pid, 'sigterm') -- The script `exec`s the supervisor.
obj:wait(5000)
return {
vim.uv.fs_stat(vim.fs.dirname(sock)) == nil,
(vim.wait(5000, function()
return vim.uv.kill(server, 0) ~= 0
end)),
}
end)
client:close()
eq({}, n.exec_lua('return _G.confirm_log'))
)
Screen.new()
-- Scheme is case-insensitive.
n.command('connect SSH://test-server')
eq(1, #n.exec_lua('return _G.confirm_log'))
eq(1, #n.api.nvim_list_uis())
eq(nil, vim.uv.fs_stat(fake_dir .. '/tunnel.args'))
-- The UI could not reach a restarted server through the tunnel.
n.clear({ env = { __NVIM_STARTREASON = 'remote-ssh' } })
eq(
'Vim(restart):E6103: :restart is not supported with remote-ssh',
t.pcall_err(n.command, 'restart')
)
end)
it('fails when ssh exits before the remote Nvim is ready', function()
setup_fake_ssh({
tunnel = { stderr = 'channel 0: open failed: administratively prohibited\n', exit = 255 },
})
n.exec_lua(function()
vim.api.nvim_create_autocmd('Progress', {
pattern = 'remote-ssh',
callback = function(ev)
_G.last_progress = ('%s: %s'):format(ev.data.status, table.concat(ev.data.text))
end,
})
end)
t.matches(
'SSH tunnel exited before the remote Nvim was ready: channel 0: open failed',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq('failed: Failed to connect to ssh://user@test-server', n.exec_lua('return _G.last_progress'))
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
it('fails when the remote Nvim crashes during startup, or is unreachable', function()
setup_fake_ssh({
tunnel = { stdout = 'NVIM_CRASHED\n', exit = 1 },
})
t.matches(
'Remote Nvim crashed during startup',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
-- The script runs, but sshd refused the forward.
setup_fake_ssh({
tunnel = { stdout = 'NVIM_READY\n' },
})
t.matches(
'Remote Nvim is not reachable through the SSH forward',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').connect('ssh://user@test-server')]])
)
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
it('serves each session from a new remote directory', function()
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local function remote_dir()
n.exec_lua([[
local _, teardown = require('vim.net._ssh').connect('ssh://user@test-server')
teardown()
]])
local args = t.read_file(fake_dir .. '/tunnel.args')
return args:match(':(/tmp/nvim%-remote%.%x+)/nvim%.sock ')
end
local dir1, dir2 = remote_dir(), remote_dir()
assert(dir1 and dir2, 'remote socket path not found')
assert(dir1 ~= dir2, 'remote socket directory reused across sessions')
end)
it(':connect ssh:// does nothing without a UI, or if the user declines', function()
setup_fake_ssh({ confirm = 0 })
eq('Vim(connect):E477: No ! allowed', t.pcall_err(n.command, 'connect! ssh://test-server'))
eq('Vim(connect):E5768: No UI attached', t.pcall_err(n.command, 'connect ssh://test-server'))
-- Typed by a client that is not a UI.
n.command('let v:errmsg = ""')
local client = n.connect(n.eval('v:servername'))
client:request('nvim_input', ':connect ssh://test-server<CR>')
t.retry(nil, nil, function()
eq('E5768: No UI attached', n.eval('v:errmsg'))
end)
client:close()
eq({}, n.exec_lua('return _G.confirm_log'))
Screen.new()
-- Scheme is case-insensitive.
n.command('connect SSH://test-server')
eq(1, #n.exec_lua('return _G.confirm_log'))
eq(1, #n.api.nvim_list_uis())
eq(nil, vim.uv.fs_stat(fake_dir .. '/tunnel.args'))
end)
end)