diff --git a/runtime/doc/gui.txt b/runtime/doc/gui.txt index 003d81af2a..64f6bc8ca8 100644 --- a/runtime/doc/gui.txt +++ b/runtime/doc/gui.txt @@ -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 diff --git a/runtime/lua/vim/health/health.lua b/runtime/lua/vim/health/health.lua index f88a51e60e..5ed5b7fc58 100644 --- a/runtime/lua/vim/health/health.lua +++ b/runtime/lua/vim/health/health.lua @@ -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 diff --git a/runtime/lua/vim/net/_ssh.lua b/runtime/lua/vim/net/_ssh.lua index 972ced6ae6..5769d5a4f1 100644 --- a/runtime/lua/vim/net/_ssh.lua +++ b/runtime/lua/vim/net/_ssh.lua @@ -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 diff --git a/runtime/scripts/remote_ssh.sh b/runtime/scripts/remote_ssh.sh index ef46188933..6412706290 100644 --- a/runtime/scripts/remote_ssh.sh +++ b/runtime/scripts/remote_ssh.sh @@ -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; 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 "$@" diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 001da6ae69..15061bb544 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -4896,7 +4896,8 @@ static void ex_exmode(exarg_T *eap) /// ":restart +cmd ": restart the Nvim server using ":cmd" and runs 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; diff --git a/test/functional/fixtures/fake-ssh.lua b/test/functional/fixtures/fake-ssh.lua index ee508b295a..76ab935557 100644 --- a/test/functional/fixtures/fake-ssh.lua +++ b/test/functional/fixtures/fake-ssh.lua @@ -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) diff --git a/test/functional/lua/ssh_spec.lua b/test/functional/lua/ssh_spec.lua index e827176721..429385970f 100644 --- a/test/functional/lua/ssh_spec.lua +++ b/test/functional/lua/ssh_spec.lua @@ -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') - 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') + 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)