feat(remote-ssh): lifecycle, cleanup, slow tests

- SshHost fields: follow ssh's "first wins" rule, so e.g. a later User
  line in the same block no longer overrides an earlier one.
- Local server now exits on its own when the remote session ends.
- Before auto-install, try existing `nvim` found in remote `$PATH`.
- Combine the remote_ssh_x.sh scripts into one remote_ssh.sh script.
- Ignore `RemoteCommand` from ssh_config, which made ssh refuse our command.
- Don't accumulate the remote heartbeat in memory for the whole session:
  `run_wait()` captures output only until it returns.
- Before detaching the UI, check that the remote Nvim answers through the
  forward: sshd may refuse the forward, but still run the script.
- Stop the session (tunnel, remote Nvim) if any connect step fails, or if
  the UI detached meanwhile.
- Run `ssh -G` once per session, instead of for every ssh command.
- Remote script handles signals at once, instead of after the heartbeat's
- Error if :restart is attempted.
  FUTURE: Remote :restart prints a marker, with its [command], on
  stdout. It also sends the TUI a restart event pointing at the LOCAL
  server. The TUI reattaches there. Local server repeats the remote-ssh
  connect steps, and sends the TUI a connect event for the new forwarded
  socket. Then it runs [command] (e.g. session restore) on the new
  remote over RPC.
This commit is contained in:
Justin M. Keyes committed 2026-11-03 19:05:58 +01:00
1 parent b94cdb848e
commit 62f79bd7e1
23 files changed
+865 -604

No files matched your search

+1 -1
View File
@@ -248,7 +248,7 @@ add_glob_target(
TARGET lintsh
COMMAND ${SHELLCHECK_PRG}
FLAGS -x -a
GLOB_DIRS scripts
GLOB_DIRS scripts runtime/scripts
GLOB_PAT *.sh
TOUCH_STRATEGY PER_DIR)
+1 -1
View File
@@ -127,5 +127,5 @@ foreach(D ${RUNTIME_DIRS})
endif()
endforeach()
# only foo.sh script in runtime/
# Only runtime/ script installed as executable.
install_helper(PROGRAMS ${CMAKE_CURRENT_SOURCE_DIR}/scripts/less.sh DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/nvim/runtime/scripts/)
+42 -2
View File
@@ -127,9 +127,10 @@ Restart Nvim
------------------------------------------------------------------------------
Connect UI to a different server
:connect {address} *:connect*
:connect {address} *:connect* *E6102*
Attaches the UI to another Nvim instance: detaches from the
current server, then attaches to {address}.
current server, then attaches to {address}. If {address} is
an `ssh://` URI, see |remote-ssh|.
Note: If the current UI hasn't implemented the "connect" UI
event, this command is equivalent to |:detach|.
@@ -138,6 +139,45 @@ Connect UI to a different server
Same as |:connect| but it also stops the detached server if
no other UI is currently attached to it.
:connect ssh://[{user}@]{host}[:{port}] *remote-ssh* *E6101* *E6103*
Connects to Nvim on {host} over SSH:
1. Uses `nvim` from `$PATH` on {host} if it is the local
version or newer (never for a local prerelease). Else
auto-installs Nvim on the remote system (based on the
current Nvim version). (Note: `$PATH` is derived from
a non-login shell, your shell rc isn't applied.)
2. Starts Nvim on {host}.
3. Attaches the UI to it, through an SSH tunnel owned by the
current (local) server.
The session ends when either side exits or the connection
drops: the remote Nvim stops, and so does the local server,
unless a UI is attached to it or a buffer is modified.
The remote Nvim runs with `NVIM_APPNAME=nvim-remote` (see
|$NVIM_APPNAME|), so its config/data are separate from other
Nvims.
- 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/`.
Requirements:
- Local: `ssh` (OpenSSH 7.6+). Win32-OpenSSH lacks
multiplexing (`ControlMaster`), thus not supported.
- Local: key-based authentication. `ssh` runs with
`BatchMode=yes`, so it never prompts. An unknown host key is
accepted and recorded, unless your ssh_config sets
`StrictHostKeyChecking`.
- Remote: Linux (x86_64, aarch64) or macOS (arm64, x86_64),
with `curl`, `tar`, POSIX `sh`, and a writable `/tmp`.
Examples: >vim
:connect ssh://myhost
:connect ssh://user@192.168.1.100
:connect ssh://root@server:2222
<
------------------------------------------------------------------------------
GUI commands
+1 -2
View File
@@ -318,6 +318,7 @@ EDITOR
• |:restart| saves/restores the current session (window layout, buffers, …).
• |:restart!| (with a bang "!") does not save/restore the session.
• |ZR| restarts Nvim (|:restart|).
• |:connect| supports `ssh://` URIs. |remote-ssh|
• |: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
@@ -345,8 +346,6 @@ EDITOR
< Typing õõ in |Normal-mode| now invokes a mapping bound to ]].
• New |complete-items| field "commit_chars": characters that accept the
selected completion item when typed.
• |:connect| with `ssh://` URI orchestrates a remote Nvim session over SSH.
See |remote-ssh|.
EVENTS
-91
View File
@@ -1,91 +0,0 @@
*remote_ssh.txt* Nvim
NVIM REFERENCE MANUAL
Remote SSH *remote-ssh* *remote-ssh-plugin*
Nvim can connect to remote SSH servers.
Using the |:connect| command with an `ssh://` URI, Nvim will automatically
connect to a host, bootstrap itself, and connect the local UI to the remote
headless server.
Type |gO| to see the table of contents.
==============================================================================
1. Introduction *remote-ssh-introduction* *E6101*
The remote-ssh feature uses `ssh` to connect to a remote system. It:
- Detects the remote architecture.
- Installs the Nvim release matching the local version (the current nightly
for prerelease builds) under `~/.local/share/nvim-remote/releases/`,
leaving any Nvim already installed on the remote host untouched.
- Starts the remote Nvim with `NVIM_APPNAME=nvim-remote`, keeping remote
state separate from any local Nvim installation on the remote host.
- Initiates a headless Nvim server and connects your local UI via a Unix
domain socket.
- On disconnect, kills the remote headless process and SSH mux socket, but
leaves the persistent data directory (`~/.local/share/nvim-remote`) intact
for fast reconnects.
==============================================================================
2. Usage *remote-ssh-usage*
From the command line: ~
Connect to a remote server using `-c` with |:connect|. SSH key-based
authentication is required; password prompts are not supported.
>sh
nvim -c 'connect ssh://user@192.168.1.100'
nvim -c 'connect ssh://root@server:2222'
<
From a running Nvim session: ~
`:connect` ssh://{uri}
Connects to {uri} over SSH. The remote-ssh feature bootstraps the remote
host and reconnects the local UI to the remote server.
>vim
:connect ssh://user@192.168.1.100
:connect ssh://root@server:2222
<
`:connect` {address}
Without the `ssh://` prefix, `:connect` connects to a local server at the
given address (e.g. a Unix socket path).
==============================================================================
3. Requirements *remote-ssh-requirements*
Both local and remote systems must meet certain requirements:
Local (Host):
- `ssh` (OpenSSH 4.0+) installed and accessible in `$PATH`.
- Multiplexing support (`ControlMaster`). Note: Win32-OpenSSH does not
support multiplexing, so Windows clients are not supported currently.
- `/tmp` directory must be writable.
Remote (Target):
- Linux (x86_64, aarch64) or macOS (arm64, x86_64).
- `tar`, `curl` to download and extract the Nvim release.
- A POSIX `sh`; the login shell itself may be any shell.
- A writable `/tmp` for the server socket.
==============================================================================
4. Remote Environment *remote-ssh-configs*
The remote Nvim starts with `NVIM_APPNAME=nvim-remote`, which isolates its
configuration and data directories from any existing Nvim installation on
the remote host. See |$NVIM_APPNAME|.
Remote paths used:
- Config: `~/.config/nvim-remote/`
- Data: `~/.local/share/nvim-remote/`
- Binary: `~/.local/share/nvim-remote/releases/{version}/bin/nvim`
The data directory persists across connections for fast reconnects. On
teardown, only the headless process and SSH mux socket are cleaned up.
==============================================================================
vim:tw=78:ts=8:ft=help:norl:
+4 -3
View File
@@ -575,9 +575,10 @@ v:stacktrace *v:stacktrace* *stacktrace-variabl
v:startreason *v:startreason* *startreason-variable*
The reason Nvim started. Possible values:
- "normal" Normal startup, yearning for life, etc.
- "restart" Started by |:restart|.
- "restart!" Started by |:restart!|.
- "normal" Normal startup, yearning for life, etc.
- "restart" Started by |:restart|.
- "restart!" Started by |:restart!|.
- "remote-ssh" Started by |remote-ssh|.
Read-only.
+32 -17
View File
@@ -49,8 +49,8 @@ local M = {}
--- @class (package) vim.SystemRunWaitResult : vim.SystemObj
--- @field code? integer Exit code, if the process exited before the predicate matched.
--- @field signal? integer Exit signal, if the process exited before the predicate matched.
--- @field stdout string Captured stdout so far.
--- @field stderr string Captured stderr so far.
--- @field stdout string Stdout until `run_wait()` returned (see its `on_exit`).
--- @field stderr string Stderr until `run_wait()` returned.
--- @class (package) vim.SystemState
--- @field cmd string[]
@@ -228,26 +228,40 @@ end
--- @param opts? { stdin?: string } `stdin` is written to the process, then closed.
--- @param wait_until? fun(stdout: string, stderr: string): boolean Predicate checked after output.
--- @param timeout? integer Time limit in ms.
--- @param on_exit? fun(out: vim.SystemCompleted) Called (in a fast context) when the process exits,
--- also if that is after this returns. Output is captured only until this returns (a long-lived
--- process may keep writing, e.g. a heartbeat), so `out` has no `stdout` or `stderr`.
--- @return vim.SystemRunWaitResult
function M.run_wait(cmd, opts, wait_until, timeout)
function M.run_wait(cmd, opts, wait_until, timeout, on_exit)
vim.validate('cmd', cmd, 'table')
vim.validate('opts', opts, 'table', true)
vim.validate('wait_until', wait_until, 'function', true)
vim.validate('timeout', timeout, 'number', true)
vim.validate('on_exit', on_exit, 'function', true)
local obj = vim.system(cmd, { text = true, stdin = opts and opts.stdin }) --[[@as vim.SystemRunWaitResult]]
local out, err = {}, {} --- @type string[], string[]
local capturing = true
local obj = vim.system(cmd, {
stdin = opts and opts.stdin,
stdout = function(e, data)
assert(not e, e)
if capturing and data then
out[#out + 1] = (data:gsub('\r\n', '\n'))
end
end,
stderr = function(e, data)
assert(not e, e)
if capturing and data then
err[#err + 1] = (data:gsub('\r\n', '\n'))
end
end,
}, on_exit) --[[@as vim.SystemRunWaitResult]]
local state = obj._state
local function stdout()
return table.concat(state.stdout_data or {})
end
local function stderr()
return table.concat(state.stderr_data or {})
end
local success = vim.wait(timeout or vim._maxint, function()
return state.result ~= nil or (wait_until and wait_until(stdout(), stderr())) or false
local success, status = vim.wait(timeout or vim._maxint, function()
return state.result ~= nil
or (wait_until and wait_until(table.concat(out), table.concat(err)))
or false
end, 50, true)
if not success then
@@ -255,13 +269,14 @@ function M.run_wait(cmd, opts, wait_until, timeout)
obj:kill('sigterm')
end
obj:wait(1000)
error('command timed out')
error(status == -2 and 'command interrupted' or 'command timed out')
end
obj.code = state.result and state.result.code or nil
obj.signal = state.result and state.result.signal or nil
obj.stdout = state.result and state.result.stdout or stdout()
obj.stderr = state.result and state.result.stderr or stderr()
obj.stdout = table.concat(out)
obj.stderr = table.concat(err)
capturing = false
return obj
end
+4 -3
View File
@@ -674,9 +674,10 @@ vim.v.shell_error = ...
vim.v.stacktrace = ...
--- The reason Nvim started. Possible values:
--- - "normal" Normal startup, yearning for life, etc.
--- - "restart" Started by `:restart`.
--- - "restart!" Started by `:restart!`.
--- - "normal" Normal startup, yearning for life, etc.
--- - "restart" Started by `:restart`.
--- - "restart!" Started by `:restart!`.
--- - "remote-ssh" Started by `remote-ssh`.
---
--- Read-only.
--- @type string
+14 -33
View File
@@ -675,41 +675,22 @@ local function check_external_tools()
if ssh_job.code == 0 then
health.ok(('%s (%s)'):format(ssh_out, ssh_path))
local version_str = ssh_out:match('OpenSSH_([%d%.]+)')
local ver = version_str and vim.version.parse(version_str)
if ver then
if vim.fn.has('win32') == 1 then
health.warn(
'Win32-OpenSSH does not support ControlMaster (multiplexing). Remote SSH features might be degraded or unsupported.'
)
elseif ver >= vim.version.parse('4.0') then
health.ok('OpenSSH version supports multiplexing')
else
health.warn(
'OpenSSH version is older than 4.0. Multiplexing is not supported, remote connections may be slow or fail.'
)
end
if vim.fn.has('win32') == 1 then
health.warn(
'Win32-OpenSSH lacks ControlMaster (multiplexing), so :connect ssh:// is not supported.'
)
else
health.warn('Could not determine OpenSSH version. Multiplexing support is unknown.')
end
if vim.fn.has('win32') == 0 then
local ssh_g = vim.system({ ssh_path, '-G', 'example.com' }):wait()
if ssh_g.code == 0 then
local ssh_cfg = ssh_g.stdout or ''
local controlmaster = ssh_cfg:match('controlmaster%s+(%S+)')
local controlpath = ssh_cfg:match('controlpath%s+(%S+)')
if controlmaster and controlmaster ~= 'no' then
health.ok(('SSH multiplexing configured (ControlMaster=%s)'):format(controlmaster))
elseif controlpath and controlpath ~= 'none' then
health.ok(('SSH ControlPath resolved to: %s'):format(controlpath))
else
health.info(
'SSH multiplexing not configured in ssh_config. :connect will set it automatically.'
)
end
-- Asks `ssh` itself (ignoring ssh_config): `-G` fails on an option it does not support.
-- `accept-new` (OpenSSH 7.6) is the newest option that :connect ssh:// uses.
local cmd = { ssh_path, '-F', 'none', '-G', '-o', 'StrictHostKeyChecking=accept-new', 'x' }
local check = vim.system(cmd, { text = true }):wait()
if check.code == 0 then
health.ok('ssh supports :connect ssh://')
else
health.warn('`ssh -G` failed. Cannot verify SSH multiplexing configuration.')
health.warn(
'ssh lacks StrictHostKeyChecking=accept-new (OpenSSH 7.6+), needed by :connect ssh://',
{ vim.trim(check.stderr or '') }
)
end
end
else
+254 -196
View File
@@ -6,6 +6,14 @@ local M = {}
local whitespace_pattern = '%s'
local line_break_pattern = '[\r\n]'
--- Command that runs ssh. Tests replace it with a fake.
M.ssh_cmd = { 'ssh' }
--- Remote-ssh data dir, relative to the remote `$HOME`.
local remote_data_dir = '.local/share/nvim-remote'
local _log --- @type vim.Log?
---@param param string
local function is_multi_value_directive(param)
local multi_value_directives = {
@@ -23,14 +31,14 @@ local function is_multi_value_directive(param)
end
---@class vim.net.SshHost
---@field alias string Host alias from ssh config
---@field hostname? string Resolved Hostname directive
---@field user? string User directive
---@field port? string Port directive
---@field identity_file? string IdentityFile directive
---@field alias string Host alias from ssh config.
---@field hostname? string Resolved Hostname directive.
---@field user? string User directive.
---@field port? string Port directive.
---@field identity_file? string IdentityFile directive.
---@param text string The ssh configuration which needs to be parsed
---@return vim.net.SshHost[] The parsed host configurations
---@param text string The ssh configuration which needs to be parsed.
---@return vim.net.SshHost[] The parsed host configurations.
function M.parse_ssh_config(text)
local i = 1
local line = 1
@@ -104,7 +112,7 @@ function M.parse_ssh_config(text)
end
if quoted or escaped then
error('Unexpected line break at line ' .. line)
error(('Unexpected line break at line %d'):format(line))
end
return vim.trim(table.concat(val))
@@ -147,7 +155,7 @@ function M.parse_ssh_config(text)
end
if quoted or escaped then
error('Unexpected line break at line ' .. line)
error(('Unexpected line break at line %d'):format(line))
end
if #val > 0 then
@@ -236,18 +244,17 @@ function M.parse_ssh_config(text)
end
add_aliases(valid)
else
local field = ({
hostname = 'hostname',
user = 'user',
port = 'port',
identityfile = 'identity_file',
})[lp]
local val = (type(node.value) == 'string' and node.value or nil) --[[@as string?]]
if val then
if field and val then
-- As ssh: the first obtained value wins.
for _, h in ipairs(current_hosts) do
if lp == 'hostname' then
h.hostname = val
elseif lp == 'user' then
h.user = val
elseif lp == 'port' then
h.port = val
elseif lp == 'identityfile' then
h.identity_file = val
end
h[field] = h[field] or val
end
end
end
@@ -259,15 +266,15 @@ function M.parse_ssh_config(text)
end
---@param filename string
---@return vim.net.SshHost[] The host configurations in the file
---@return vim.net.SshHost[] The host configurations in the file.
function M.parse_config(filename)
local text = vim.fn.readblob(filename)
return M.parse_ssh_config(text)
end
---@param filename? string Path to the SSH config file. Defaults to ~/.ssh/config
---@return string[] The hostnames configured in the SSH config file.
--- Note: This does not currently process `Include` directives.
---@param filename? string Path to the SSH config file. Defaults to `~/.ssh/config`.
---@return string[] The hostnames configured in the SSH config file. Note: This does not process
--- `Include` directives.
function M.get_hosts(filename)
filename = filename or vim.fs.normalize('~/.ssh/config')
local ok, hosts = pcall(M.parse_config, filename)
@@ -283,37 +290,30 @@ function M.get_hosts(filename)
)
end
local _log --- @type vim.Log?
--- Lazy `vim.log` instance for the remote-ssh feature. Writes to `stdpath('log')/remote-ssh.log`.
---@return vim.Log
local function log()
if not _log then
_log = vim.log.new({ name = 'remote-ssh', current_level = vim.log.levels.INFO })
_log = vim.log.new({ name = 'remote-ssh', level = vim.log.levels.INFO })
end
return _log
end
---@param msg string
---@param level? integer vim.log.levels.* (default INFO)
local function notify(msg, level)
level = level or vim.log.levels.INFO
if level >= vim.log.levels.ERROR then
log().error(msg)
elseif level >= vim.log.levels.WARN then
log().warn(msg)
else
log().info(msg)
end
local function show()
vim.notify('remote-ssh: ' .. msg, level)
--- Returns a function that shows (and logs) connect status as one |progress-message|, updated in
--- place.
---@return fun(status: 'running'|'success'|'failed', msg: string)
local function new_progress_report()
local progress = { kind = 'progress', source = 'remote-ssh', title = 'remote-ssh' }
return function(status, msg)
if status == 'failed' then
log().error(msg)
else
log().info(msg)
end
progress.status = status
progress.id = vim.api.nvim_echo({ { msg } }, status ~= 'running', progress)
vim.cmd.redraw()
end
if vim.in_fast_event() then
vim.schedule(show)
else
show()
end
end
local function core_system()
@@ -321,44 +321,63 @@ local function core_system()
return require('vim._core.system')
end
--- Builds a command for the host identified by `ssh_uri`.
--- Builds a command for the remote host identified by `ssh_uri`.
---
--- `ssh_args` are inserted before the target; `remote_cmd` is appended after the target.
---
---@param ssh_uri {host:string, user?:string, port?:string}
---@param ssh_uri {host:string, user?:string, port?:string, config?:string}
---@param opts? { ssh_args?: string[], remote_cmd?: string[] }
---@return string[]
local function get_ssh_cmd(ssh_uri, opts)
opts = opts or {}
local mux_dir = vim.fn.stdpath('cache') .. '/ssh' --[[@as string]]
local mux_dir = vim.fs.joinpath(vim.fn.stdpath('cache') --[[@as string]], 'ssh')
vim.fn.mkdir(mux_dir, 'p')
-- `%C` is SSH's hash of (localhost, host, port, user).
-- Its fixed length keeps the ControlPath socket under the 104-byte `sun_path` limit,
-- which the raw `%h_%p_%r` name plus a long base dir can exceed.
local mux_path = mux_dir .. '/mux_%C'
local ssh_cmd = {
'ssh',
-- `%C` is SSH's hash of (local hostname, remote host, port, user). Its fixed length keeps the
-- ControlPath socket under the 104-byte `sun_path` limit, which the raw `%h_%p_%r` name plus a
-- long base dir can exceed. See also |vim.fs.slug()|.
local mux_path = vim.fs.joinpath(mux_dir, 'mux_%C')
local ssh_cmd = vim.list_extend(vim.list_slice(M.ssh_cmd), {
'-T',
-- Fails instead of prompting for a password or key passphrase.
'-o',
'BatchMode=yes',
'-o',
'ControlMaster=auto',
'-o',
'ControlPath=' .. mux_path,
('ControlPath=%s'):format(mux_path),
'-o',
'ControlPersist=10m',
}
-- Workaround "Cannot execute command-line and remote command" if `RemoteCommand` is in user
-- ssh_config.
'-o',
'RemoteCommand=none',
})
local target = ssh_uri.user and ('%s@%s'):format(ssh_uri.user, ssh_uri.host) or ssh_uri.host
local dest = ssh_uri.port and { '-p', ssh_uri.port } or {}
-- Ends option parsing, so a target like `-oProxyCommand=x` is not taken as an option.
vim.list_extend(dest, { '--', target })
-- Effective ssh_config. Once per `ssh_uri`: `ssh -G` evaluates e.g. `Match exec`, or DNS for
-- `CanonicalizeHostname`.
if not ssh_uri.config then
local cmd = vim.list_extend(vim.list_slice(M.ssh_cmd), { '-G', unpack(dest) })
ssh_uri.config = vim.system(cmd, { text = true }):wait().stdout or ''
end
local config = ('\n%s'):format(ssh_uri.config)
-- At the default `ask`, BatchMode fails on an unknown host key. `accept-new` records it instead,
-- as if the user typed "yes"; a changed key still fails. An explicit ssh_config value is kept.
if config:match('\nstricthostkeychecking (%S+)') == 'ask' then
vim.list_extend(ssh_cmd, { '-o', 'StrictHostKeyChecking=accept-new' })
end
-- At the default 0, a dropped connection (e.g. after sleep) leaves the session frozen until TCP
-- gives up. 15s, times the default `ServerAliveCountMax` 3: ends the session within 45s.
if config:match('\nserveraliveinterval (%S+)') == '0' then
vim.list_extend(ssh_cmd, { '-o', 'ServerAliveInterval=15' })
end
if opts.ssh_args then
vim.list_extend(ssh_cmd, opts.ssh_args)
end
if ssh_uri.port then
table.insert(ssh_cmd, '-p')
table.insert(ssh_cmd, ssh_uri.port)
end
---@type string
local target = ssh_uri.host
if ssh_uri.user then
target = ssh_uri.user .. '@' .. ssh_uri.host
end
table.insert(ssh_cmd, target)
vim.list_extend(ssh_cmd, dest)
if opts.remote_cmd then
vim.list_extend(ssh_cmd, opts.remote_cmd)
@@ -367,17 +386,25 @@ local function get_ssh_cmd(ssh_uri, opts)
return ssh_cmd
end
--- Runs `script` via `sh -s` over stdin, so the remote login shell never re-parses it
--- 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`.
---
---@param uri {host:string, user?:string, port?:string}
---@param script string POSIX shell script.
---@param args string[] Script args: plain words, which no shell expands, so they need no quoting.
---@param opts? { ssh_args?: string[], wait_until?: fun(stdout: string, stderr: string): boolean }
---@param on_exit? fun(out: vim.SystemCompleted) See `run_wait()`.
---@return vim.SystemRunWaitResult
local function run_script(uri, script, opts)
local function run_script(uri, args, opts, on_exit)
opts = opts or {}
local ssh_cmd = get_ssh_cmd(uri, { ssh_args = opts.ssh_args, remote_cmd = { 'sh', '-s' } })
for _, arg in ipairs(args) do
assert(arg:match('^[%w%-%._/:]+$'), ('not a plain word: %s'):format(arg))
end
local script =
vim.fn.readblob(vim.fs.joinpath(assert(vim.env.VIMRUNTIME), 'scripts', 'remote_ssh.sh'))
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 core_system().run_wait(ssh_cmd, { stdin = script }, opts.wait_until, 300000)
return core_system().run_wait(ssh_cmd, { stdin = script }, opts.wait_until, 300000, on_exit)
end
--- Gets the operating system and architecture from the remote system.
@@ -388,15 +415,14 @@ function M.get_system_info(uri)
local ssh_cmd = get_ssh_cmd(uri, { remote_cmd = { 'uname -s && uname -m' } })
log().debug('get_system_info: running', ssh_cmd)
local obj = core_system().run_wait(ssh_cmd, nil, nil, 300000)
local obj = vim.system(ssh_cmd, { text = true }):wait(300000)
log().debug('get_system_info: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)
if obj.code ~= 0 then
error(
'Failed to detect remote system info: ' .. (obj.stderr ~= '' and obj.stderr or obj.stdout)
)
local detail = obj.stderr ~= '' and obj.stderr or obj.stdout
error(('Failed to detect remote system info: %s'):format(detail))
end
local lines = vim.split(vim.trim(obj.stdout), '\n', { plain = true })
local lines = vim.split(vim.trim(obj.stdout or ''), '\n', { plain = true })
local valid_lines = {}
for _, line in ipairs(lines) do
if vim.trim(line) ~= '' then
@@ -405,7 +431,7 @@ function M.get_system_info(uri)
end
if #valid_lines < 2 then
error('Unexpected output from system info detection: ' .. obj.stdout)
error(('Unexpected output from system info detection: %s'):format(obj.stdout))
end
---@type string
@@ -420,19 +446,22 @@ function M.get_system_info(uri)
return os, arch
end
--- Installs the matching Nvim release on the remote host, unless present.
--- Uses a per-version dir, never touching the user's own Nvim.
--- Gets the Nvim release URL/dir/version based on the local version + remote platform.
---
---@param uri {host:string, user?:string, port?:string}
---@param os string
---@param arch string
---@return string nvim Remote path of the `nvim` binary, as a shell word.
local function check_and_install(uri, os, arch)
---@return string dir Install dir, relative to the remote `$HOME`, e.g.
--- `.local/share/nvim-remote/releases/v0.12.0-linux-x86_64`. Per platform too: a shared `$HOME`
--- may be mounted on remote hosts of different platforms.
---@return string url Release archive.
---@return string? version Minimum version of an `nvim` on the remote `$PATH` to use instead, or nil
--- for a prerelease (nightlies are not ordered by version).
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
local tag = ver.prerelease and 'nightly' or base
local install_name = ver.prerelease and ('nightly-' .. base) or base
local install_name = ver.prerelease and ('nightly-%s'):format(base) or base
local os_map = { linux = 'linux', darwin = 'macos' }
local arch_map = { x86_64 = 'x86_64', aarch64 = 'arm64', arm64 = 'arm64' }
@@ -440,147 +469,176 @@ local function check_and_install(uri, os, arch)
local target_os = os_map[os]
local target_arch = arch_map[arch]
if not target_os or not target_arch then
error(string.format('Unsupported OS/Arch combination: %s/%s', os, arch))
error(('Unsupported OS/Arch combination: %s/%s'):format(os, arch))
end
local release_name = string.format('nvim-%s-%s', target_os, target_arch)
local release_url = string.format(
'https://github.com/neovim/neovim/releases/download/%s/%s.tar.gz',
local release_name = ('nvim-%s-%s'):format(target_os, target_arch)
local release_url = ('https://github.com/neovim/neovim/releases/download/%s/%s.tar.gz'):format(
tag,
release_name
)
local dest = '"$HOME"/.local/share/nvim-remote/releases/' .. install_name
local remote_script = string.format(
[[
set -eu
dest=%s
if [ -x "$dest/bin/nvim" ]; then
exit 0
fi
echo "Installing Nvim %s..." >&2
mkdir -p "${dest%%/*}"
tmp=$(mktemp -d "$dest.XXXXXX")
trap 'rm -rf "$tmp"' EXIT
curl -fsSL -o "$tmp/nvim.tar.gz" '%s'
tar -xzf "$tmp/nvim.tar.gz" -C "$tmp"
# atomic rename; keep a concurrent install that finished first
[ -e "$dest" ] || mv "$tmp/%s" "$dest"
]],
dest,
install_name,
release_url,
release_name
)
log().debug('check_and_install: install', install_name, 'os', target_os, 'arch', target_arch)
local obj = run_script(uri, remote_script)
log().debug('check_and_install: code', obj.code, 'stdout', obj.stdout, 'stderr', obj.stderr)
if obj.code ~= 0 then
error('Installation failed: ' .. (obj.stderr ~= '' and obj.stderr or obj.stdout))
end
return dest .. '/bin/nvim'
local dir = ('%s/releases/%s-%s-%s'):format(remote_data_dir, install_name, target_os, target_arch)
return dir, release_url, not ver.prerelease and base or nil
end
--- @param uri_str string
--- @return string local_socket path to the local forwarded socket
--- @return vim.net.SshUri uri parsed SSH URI for cleanup
--- @return fun() teardown function to close the SSH master connection
function M.start(uri_str)
local uri = require('vim.uri')._parse_ssh_uri(uri_str)
notify('Connecting to ' .. uri_str .. '...')
local os, arch = M.get_system_info(uri)
notify('Checking remote Nvim install...')
local nvim = check_and_install(uri, os, arch)
local local_sock = vim.fn.tempname() .. '_remote_nvim.sock'
-- Fresh private dir per session: `mkdir` fails if it exists,
-- so nobody can plant a socket
local token = assert(vim.uv.random(8)):gsub('.', function(c)
return ('%02x'):format(c:byte())
--- Sends a request to the Nvim server at `addr`, and waits for its result. Over a plain socket, not
--- an RPC channel: that would also let the (remote) server call the API of this (local) Nvim.
---@param addr string
---@param method string
---@param params any[]
---@return boolean ok
local function request(addr, method, params)
local pipe = assert(vim.uv.new_pipe())
local received = ''
local response --- @type any
local done = false
pipe:connect(addr, function(err)
if err then
done = true
return
end
pipe:write(vim.mpack.encode({ 0, 0, method, params }))
pipe:read_start(function(read_err, data)
if read_err or not data then
done = true
return
end
received = ('%s%s'):format(received, data)
local ok, msg = pcall(vim.mpack.decode, received)
if ok then
response = msg
done = true
end
end)
end)
local remote_dir = '/tmp/nvim-remote.' .. token
local remote_sock = remote_dir .. '/nvim.sock'
vim.wait(30000, function()
return done
end)
pipe:close()
-- Response: `[1, msgid, error, result]`.
return response ~= nil and response[1] == 1 and response[3] == vim.NIL
end
local remote_cmd = string.format(
[[
dir='%s'
sock="$dir/nvim.sock"
mkdir -m 700 "$dir" || exit 1
NVIM_PID=
trap 'kill $NVIM_PID 2>/dev/null; rm -rf "$dir"' EXIT
trap 'exit 1' HUP INT TERM PIPE
NVIM_APPNAME=nvim-remote %s --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"
# no pty, so no SIGHUP on disconnect: a failed heartbeat write triggers the EXIT trap
while kill -0 $NVIM_PID 2>/dev/null; do
sleep 1
printf '\n' 2>/dev/null || exit 1
done
]],
remote_dir,
nvim
)
--- See `M.start()`.
---@param chan? integer
---@param report fun(status: 'running'|'success'|'failed', msg: string)
local function start(uri_str, chan, report)
local uri = require('vim.uri')._parse_ssh_uri(uri_str)
report('running', ('Connecting to %s...'):format(uri_str))
local dir, url, version = get_release(M.get_system_info(uri))
local forward = local_sock .. ':' .. remote_sock
local local_sock = ('%s_remote_nvim.sock'):format(vim.fn.tempname())
local token = vim.text.hexencode(assert(vim.uv.random(8)))
local remote_sock = ('/tmp/nvim-remote.%s/nvim.sock'):format(token)
notify('Establishing SSH tunnel...')
log().debug('start: local_sock', local_sock)
local tunnel = run_script(uri, remote_cmd, {
ssh_args = { '-L', forward },
wait_until = function(stdout)
return stdout:match('NVIM_READY') ~= nil
end,
})
log().debug('start: tunnel stdout', tunnel.stdout, 'stderr', tunnel.stderr)
local forward = ('%s:%s'):format(local_sock, remote_sock)
local cleaned_up = false
local tunnel --- @type vim.SystemRunWaitResult
-- no NVIM_READY means the tunnel already exited
if not tunnel.stdout:match('NVIM_READY') then
-- the forward lives in the ssh master, which outlives this session
vim.system(get_ssh_cmd(uri, { ssh_args = { '-O', 'cancel', '-L', forward } })):wait()
local msg = tunnel.stdout:match('NVIM_CRASHED') and 'Remote Nvim crashed during startup'
or 'SSH tunnel exited before the remote Nvim was ready'
log().error(msg, 'code', tunnel.code, 'stderr', tunnel.stderr)
local detail = vim.trim(tunnel.stderr)
error(detail ~= '' and (msg .. ': ' .. detail) or msg)
--- Stops this (local) server when the session ends (remote quit, crash, lost connection),
--- unless it is still in use: a UI is attached or a buffer is modified.
local function on_exit()
vim.schedule(function()
-- Without NVIM_READY there was no session (`start()` fails instead).
if cleaned_up or not (tunnel and tunnel.stdout:match('NVIM_READY')) then
return
end
local modified = vim.iter(vim.api.nvim_list_bufs()):any(function(buf)
return vim.bo[buf].modified
end)
if modified or #vim.api.nvim_list_uis() > 0 then
log().info(('session ended: %s; local server in use, keeping it'):format(uri_str))
return
end
log().info(('session ended: %s; stopping local server'):format(uri_str))
-- Not `qall!`, so a running :terminal job keeps this server alive.
local ok, err = pcall(vim.cmd.qall)
if not ok then
log().info(('local server kept: %s'):format(err))
end
end)
end
notify('Connected to ' .. uri_str)
local cleaned_up = false
--- Stops the tunnel (and thus the remote server), and cancels its forward, which lives in the ssh
--- master.
local function teardown()
if cleaned_up then
return
end
cleaned_up = true
log().info('teardown: closing ssh mux for ' .. uri_str)
local stop_cmd = get_ssh_cmd(uri, { ssh_args = { '-O', 'exit' } })
vim.system(stop_cmd):wait()
-- Wait for the original tunnel process to close its stdio handles.
log().info(('teardown: stopping tunnel for %s'):format(uri_str))
if not tunnel:is_closing() then
tunnel:wait(1000)
tunnel:kill('sigterm')
end
-- Waits for the tunnel to close its stdio handles.
tunnel:wait(1000)
vim.system(get_ssh_cmd(uri, { ssh_args = { '-O', 'cancel', '-L', forward } })):wait(1000)
end
report('running', 'Starting remote Nvim...')
log().debug('start: local_sock', local_sock)
tunnel = run_script(uri, { remote_sock, dir, url, version }, {
ssh_args = { '-L', forward },
wait_until = function(stdout)
return stdout:match('NVIM_READY') ~= nil
end,
}, on_exit)
log().debug('start: tunnel stdout', tunnel.stdout, 'stderr', tunnel.stderr)
local err --- @type string?
if not tunnel.stdout:match('NVIM_READY') then
-- The tunnel already exited.
err = tunnel.stdout:match('NVIM_CRASHED') and 'Remote Nvim crashed during startup'
or 'SSH tunnel exited before the remote Nvim was ready'
local detail = vim.trim(tunnel.stderr)
err = detail ~= '' and ('%s: %s'):format(err, detail) or err
elseif not request(local_sock, 'nvim_get_api_info', {}) then
-- sshd may refuse the forward (e.g. `AllowStreamLocalForwarding no`) but run the script.
err = 'Remote Nvim is not reachable through the SSH forward'
elseif
chan
and not vim.iter(vim.api.nvim_list_uis()):any(function(ui)
return ui.chan == chan
end)
then
err = ('UI detached: channel %d'):format(chan)
end
if err then
log().error(err, 'code', tunnel.code, 'stderr', tunnel.stderr)
teardown()
error(err)
end
report('success', ('Connected to %s'):format(uri_str))
vim.api.nvim_create_autocmd('VimLeavePre', {
callback = teardown,
})
return local_sock, uri, teardown
return local_sock, teardown
end
--- Asks to confirm, then connects.
---
--- @param uri_str string
--- @param chan? integer UI to connect: fails (and stops the session) if it detached meanwhile.
--- @return string? local_socket Path to the local forwarded socket, or nil if declined.
--- @return fun()? teardown Stops this session's tunnel (and thus the remote server). Other sessions
--- sharing the SSH master are unaffected.
function M.start(uri_str, chan)
local msg = ('This will install Nvim on %s (in ~/%s/), if missing.\n'):format(
uri_str,
remote_data_dir
)
if vim.fn.confirm(msg, 'Connect? &Yes\n&No', 1, 'Question') ~= 1 then
return nil
end
local report = new_progress_report()
local ok, local_sock, teardown = pcall(start, uri_str, chan, report)
if not ok then
report('failed', ('Failed to connect to %s'):format(uri_str))
error(local_sock, 0)
end
return local_sock, teardown
end
return M
+15 -17
View File
@@ -221,28 +221,26 @@ end
---@field user? string
---@field port? string
--- Splits `ssh://[user@]host[:port][/path]` or `[user@]host[:port]`. Drops the path, and the
--- brackets of an IPv6 host: `ssh://u@[::1]:22/` => `{ user = 'u', host = '::1', port = '22' }`.
--- Percent-decodes the user and host: `ssh://a%40b@h` => `{ user = 'a@b', host = 'h' }`. Fails if
--- the host is empty.
---@param str string
---@return vim.net.SshUri
function M._parse_ssh_uri(str)
---@type vim.net.SshUri
local uri = { host = '' }
---@type string?
local scheme_match = str:match('^ssh://(.*)')
if scheme_match then
str = scheme_match
local parsed = M.parse(str)
local authority = parsed and parsed.authority or str
local user, host = authority:match('^(.*)@(.*)$') --- @type string?, string?
host = host or authority
local port = host:match(':(%d+)$') --- @type string?
if port then
host = host:sub(1, -#port - 2)
end
local user, rest = str:match('^(.-)@(.*)$')
if user and rest then
uri.user = user
str = rest
host = M.decode(host:match('^%[(.*)%]$') or host)
if host == '' then
error(('No host in SSH URI: %s'):format(str), 0)
end
local port_match = str:match(':(%d+)$')
if port_match then
uri.port = port_match
str = str:sub(1, -(#port_match + 2))
end
uri.host = str
return uri
return { host = host, user = user and M.decode(user), port = port }
end
return M
+92
View File
@@ -0,0 +1,92 @@
#!/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).
#
# Usage: remote_ssh.sh {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.
# 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
# dash (Debian's sh) has no pipefail; tar still fails on an empty or truncated stream.
# shellcheck disable=SC3040
if (set -o pipefail) 2>/dev/null; then
set -o pipefail
fi
# Prints the path of `nvim` on $PATH, if it is $want or newer. It checks its own version, so an old
# or broken one fails.
# TODO: $PATH is that of a non-login shell, so it may lack e.g. `~/.local/bin`. Could take it from
# a throwaway login shell: `"$SHELL" -lc 'printf %s "$PATH"'`.
system_nvim() {
[ -n "$want" ] || return 1
nvim=$(command -v nvim) || return 1
want=$want "$nvim" --clean -es \
+'lua os.exit(vim.version.ge(vim.version(), os.getenv("want")) and 0 or 1)' \
</dev/null >/dev/null 2>&1 || return 1
echo "$nvim"
}
# Installs the release to $dest, unless present.
install_release() (
if [ -x "$dest/bin/nvim" ]; 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"
# 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"
)
# 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() {
sock=$1
dest="$HOME/$2"
url=$3
want=${4-}
if ! nvim=$(system_nvim); then
install_release
nvim="$dest/bin/nvim"
fi
start_server "$nvim"
heartbeat
}
main "$@"
+6
View File
@@ -318,6 +318,12 @@ void nvim_ui_detach(uint64_t channel_id, Error *err)
remote_ui_disconnect(channel_id, err, false);
}
/// Checks that a UI is attached on channel `channel_id`.
bool remote_ui_attached(uint64_t channel_id)
{
return map_has(uint64_t, &connected_uis, channel_id);
}
// Sends a "connect" event to the UI on the given channel
void remote_ui_connect(uint64_t channel_id, char *server_addr, Error *err)
{
+2 -1
View File
@@ -352,7 +352,8 @@ void evalvars_init(void)
// Set v:startreason via environment variable
const char *startreason = os_getenv_noalloc(ENV_STARTREASON);
if (strequal(startreason, "restart!") || strequal(startreason, "restart")) {
if (strequal(startreason, "restart!") || strequal(startreason, "restart")
|| strequal(startreason, "remote-ssh")) {
set_vim_var_string(VV_STARTREASON, startreason, -1);
}
if (os_env_exists(ENV_STARTREASON, false)) {
+7 -5
View File
@@ -103,17 +103,18 @@ static void connect_close_cb(Stream *stream, void *data)
*closed = true;
}
/// Check if a socket is alive by attempting to connect to it.
/// Checks if a socket is alive by attempting to connect to it.
/// @param loop Event loop
/// @param is_tcp Whether `addr` is a TCP "host:port", else a pipe.
/// @param addr Socket address to probe
/// @param[out] error Reason, if not alive.
/// @return true if socket is alive (connection succeeded), false otherwise
static bool socket_alive(Loop *loop, const char *addr)
bool socket_alive(Loop *loop, bool is_tcp, const char *addr, const char **error)
{
RStream stream = { 0 };
const char *error = NULL;
// Try to connect with a 500ms timeout (fast failure for dead sockets)
bool connected = socket_connect(loop, &stream, false, addr, 500, &error);
bool connected = socket_connect(loop, &stream, is_tcp, addr, 500, error);
if (!connected) {
return false;
}
@@ -175,7 +176,8 @@ int socket_watcher_start(SocketWatcher *watcher, int backlog, socket_cb cb)
if (result == UV_EACCES || result == UV_EADDRINUSE) {
Loop *loop = watcher->stream->loop->data;
if (!socket_alive(loop, watcher->addr)) {
const char *error = NULL;
if (!socket_alive(loop, false, watcher->addr, &error)) {
// Socket exists but is dead - remove it
ILOG("Removing stale socket: %s", watcher->addr);
int rm_result = os_remove(watcher->addr);
+31 -9
View File
@@ -4894,6 +4894,11 @@ 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.
if (strequal(get_vim_var_str(VV_STARTREASON), "remote-ssh")) {
emsg(_("E6103: :restart is not supported with remote-ssh"));
return;
}
if (!eap->forceit) {
dict_T *extra_d = tv_dict_alloc(); // Pass +cmd in the `extra` param of nlua_call_excmd.
tv_dict_add_str(extra_d, S_LEN("quit_cmd"), eap->do_ecmd_cmd ? eap->do_ecmd_cmd : "");
@@ -5863,13 +5868,13 @@ static void ex_tabs(exarg_T *eap)
/// ":%detach" detaches all UIs _except_ the current UI.
static void ex_detach(exarg_T *eap)
{
Channel *chan = find_channel(current_ui);
if (!chan) {
if (!remote_ui_attached(current_ui)) {
emsg(_(e_noui));
return;
}
if (eap && eap->forceit) {
Channel *chan = find_channel(current_ui);
chan->detach = true;
channel_event(chan, EVENT_CHANINFO);
msg(_("Nvim will continue running if the UI disconnects"), 0);
@@ -5918,23 +5923,40 @@ static void ex_connect(exarg_T *eap)
{
char *addr = eap->arg;
typval_T ssh_rettv = { .v_type = VAR_UNKNOWN };
bool is_tcp = socket_address_tcp_host_end(addr) != NULL;
const char *error = NULL;
// SSH URI: bootstrap remote Nvim, get local forwarded socket
if (strncmp(addr, "ssh://", 6) == 0) {
typval_T argvars[2];
argvars[0].v_type = VAR_STRING;
argvars[0].vval.v_string = addr;
argvars[1].v_type = VAR_UNKNOWN;
// SSH URI: bootstrap remote Nvim, get local forwarded socket.
if (STRNICMP(addr, "ssh://", 6) == 0) {
// XXX: ":connect!" not supported. The tunnel is owned by this server, stopping it would kill the session.
if (eap->forceit) {
emsg(_(e_nobang));
return;
}
if (!remote_ui_attached(current_ui)) {
emsg(_(e_noui));
return;
}
typval_T argvars[] = { { .v_type = VAR_STRING, .vval.v_string = addr },
{ .v_type = VAR_NUMBER, .vval.v_number = (varnumber_T)current_ui },
{ .v_type = VAR_UNKNOWN } };
nlua_call_typval("vim.net._ssh", "start", argvars, &ssh_rettv);
if (ssh_rettv.v_type == VAR_SPECIAL) {
return; // User declined the prompt.
}
if (ssh_rettv.v_type != VAR_STRING || ssh_rettv.vval.v_string == NULL) {
emsg("E6101: SSH connection failed");
emsg(_("E6101: SSH connection failed"));
tv_clear(&ssh_rettv);
return;
}
addr = ssh_rettv.vval.v_string;
} else if ((is_tcp || path_is_absolute(addr)) // UI CWD may differ.
&& !socket_alive(&main_loop, is_tcp, addr, &error)) {
semsg(_("E6102: Cannot connect to %s: %s"), addr, error);
return;
}
bool stop_server = eap->forceit ? (ui_active() == 1) : false;
+1 -1
View File
@@ -299,7 +299,7 @@ EXTERN bool garbage_collect_at_exit INIT( = false);
// Script CTX being sourced or was sourced to define the current function.
EXTERN sctx_T current_sctx INIT( = { 0, 0, 0, 0 });
/// Last channel that invoked 'nvim_input` or got FocusGained.
/// Last channel that invoked `nvim_input` or got FocusGained. Maybe not a UI, check remote_ui_attached().
EXTERN uint64_t current_ui INIT( = 0);
EXTERN bool did_source_packages INIT( = false);
+4 -3
View File
@@ -789,9 +789,10 @@ M.vars = {
type = 'string',
desc = [=[
The reason Nvim started. Possible values:
- "normal" Normal startup, yearning for life, etc.
- "restart" Started by |:restart|.
- "restart!" Started by |:restart!|.
- "normal" Normal startup, yearning for life, etc.
- "restart" Started by |:restart|.
- "restart!" Started by |:restart!|.
- "remote-ssh" Started by |remote-ssh|.
Read-only.
]=],
+68
View File
@@ -0,0 +1,68 @@
-- Fake `ssh` for remote-ssh tests: `nvim -l fake-ssh.lua {config} {ssh-args}`. Behaves as {config}
-- (JSON, see `setup_fake_ssh()` in ssh_spec.lua) says, and writes to its `dir`:
-- tunnel.args Args of the tunnel (`-L`) command, and the script it got on stdin.
-- tunnel.pid PID of the tunnel.
-- forward.cancelled Created by `-O cancel -L`.
io.stdout:setvbuf('no')
local config = vim.json.decode(table.remove(_G.arg, 1))
local dir = config.dir
local args = table.concat(_G.arg, ' ')
local function fail(msg)
io.stderr:write(msg, '\n')
os.exit(1)
end
local function write(name, data)
local f = assert(io.open(vim.fs.joinpath(dir, name), 'wb'))
f:write(data)
f:close()
end
if args:find('^%-G ') then
io.stdout:write(config.ssh_config)
os.exit(0)
elseif args:find('uname -s && uname -m', 1, true) then
io.stdout:write(config.uname)
os.exit(0)
elseif args:find('-O cancel -L', 1, true) then
write('forward.cancelled', '')
os.exit(0)
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'))
if not args:find('ControlMaster', 1, true) then
fail('FAIL: Multiplexing flags missing!')
elseif not args:find(' sh %-s .*NVIM_READY') then
fail('FAIL: tunnel script not fed to sh -s')
end
write('tunnel.args', args)
write('tunnel.pid', tostring(vim.uv.os_getpid()))
local tunnel = config.tunnel
local server --- @type vim.SystemObj?
if tunnel.serve then
-- Serves a headless Nvim at the `-L` socket, as the remote does. Its stdin (`--embed`) ends with
-- this process, and so does the server.
local sock = assert(args:match('%-L ([^:]+)'))
local cmd = { config.nvim_prog, '--embed', '--headless', '--clean', '-i', 'NONE' }
server = vim.system(vim.list_extend(cmd, { '--listen', sock }), { stdin = true })
vim.wait(10000, function()
return vim.uv.fs_stat(sock) ~= nil
end)
io.stdout:write('NVIM_READY\n')
end
io.stdout:write(tunnel.stdout or '')
io.stderr:write(tunnel.stderr or '')
if tunnel.exit then
os.exit(tunnel.exit)
elseif server then
-- As the remote script: ends when its server exits.
server:wait()
os.exit(0)
end
-- Until killed.
vim.wait(60000)
+242 -216
View File
@@ -1,14 +1,15 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local Screen = require('test.functional.ui.screen')
local parser = require('vim.net._ssh')
local describe, it = t.describe, t.it
local before_each, after_each = t.before_each, t.after_each
local before_each = t.before_each
local clear = n.clear
local eq = t.eq
local is_os = t.is_os
local skip = t.skip
describe('SSH parser', function()
describe('parse_ssh_config()', function()
it('parses SSH configuration strings', function()
local config = [[
Host *
@@ -106,186 +107,59 @@ describe('SSH parser', function()
end)
describe('vim.net._ssh', function()
local fake_bin_dir
local fake_dir --- @type string
--- Makes `vim.net._ssh` run a fake `ssh` (fixtures/fake-ssh.lua), and stubs `confirm()`.
---
--- @param behavior? table Optional fields:
--- - `confirm`: Result of `confirm()`.
--- - `ssh_config`, `uname`: Output of `ssh -G`, of `uname -s && uname -m`.
--- - `tunnel`: The tunnel (`-L`) command, by default `{ serve = true }`:
--- - `serve`: Serves a headless Nvim at the `-L` socket, then prints NVIM_READY.
--- - `stdout`, `stderr`: Printed.
--- - `exit`: Exit code. Else runs until the served Nvim exits, or until killed.
local function setup_fake_ssh(behavior)
behavior = behavior or {}
local is_windows = package.config:sub(1, 1) == '\\'
fake_bin_dir = t.tmpname()
os.remove(fake_bin_dir)
vim.uv.fs_mkdir(fake_bin_dir, 511)
local fake_ssh_path = fake_bin_dir .. (is_windows and '/ssh.cmd' or '/ssh')
local fake_tunnel_pid = fake_bin_dir .. '/tunnel.pid'
local dir = vim.fs.abspath(t.tmpname(false))
fake_dir = dir
n.mkdir_p(dir)
t.finally(function()
n.rmdir(dir)
end)
local nvim_prog = vim.fs.abspath(n.nvim_prog)
local config = vim.json.encode({
dir = fake_dir,
nvim_prog = nvim_prog,
ssh_config = behavior.ssh_config or 'stricthostkeychecking ask\nserveraliveinterval 0\n',
uname = behavior.uname or 'Linux\nx86_64\n',
tunnel = behavior.tunnel or { serve = true },
})
local fake_ssh = vim.fs.abspath('test/functional/fixtures/fake-ssh.lua')
local script
if is_windows then
script = t.dedent([=[
@echo off
setlocal EnableDelayedExpansion
set "ARGS=%*"
echo(!ARGS! | findstr /C:"uname -s && uname -m" >nul
if not errorlevel 1 (
]=] .. (behavior.uname or [=[
echo Linux
echo x86_64
]=]) .. [=[
exit /b 0
)
echo(!ARGS! | findstr /C:"-L" >nul
if not errorlevel 1 (
echo(!ARGS! | findstr /C:"ControlMaster" >nul
if errorlevel 1 (
echo FAIL: Multiplexing flags missing! 1>&2
exit /b 1
)
]=] .. (behavior.tunnel or [=[
set "SOCK="
set "NEXT_L="
for %%A in (%*) do (
if defined NEXT_L (
set "SOCK=%%~A"
set "NEXT_L="
) else if "%%~A"=="-L" (
set "NEXT_L=1"
)
)
if not defined SOCK (
echo Unexpected SSH command: !ARGS! 1>&2
exit /b 1
)
> "!SOCK!" type NUL
exit /b 0
]=]) .. [=[
)
echo(!ARGS! | findstr /C:"TARGET_VER" >nul
if not errorlevel 1 (
]=] .. (behavior.installer or [=[
echo Installing Neovim... 1>&2
exit /b 0
]=]) .. [=[
)
echo(!ARGS! | findstr /C:"-O" >nul
if not errorlevel 1 (
echo(!ARGS! | findstr /C:"exit" >nul
if not errorlevel 1 exit /b 0
)
echo Unexpected SSH command: !ARGS! 1>&2
exit /b 1
]=])
else
script = t.dedent([=[
#!/usr/bin/env bash
ARGS="$*"
if [[ "$ARGS" == *" sh -s" ]]; then
ARGS="$ARGS $(cat)"
fi
PID_FILE=]=] .. string.format('%q', fake_tunnel_pid) .. [=[
ARGS_FILE=]=] .. string.format('%q', fake_bin_dir .. '/tunnel.args') .. [=[
if [[ "$ARGS" == *"uname -s && uname -m"* ]]; then
]=] .. (behavior.uname or [=[
echo "Linux"
echo "x86_64"
]=]) .. [=[
exit 0
fi
if [[ "$ARGS" == *"-O cancel -L"* ]]; then
touch ]=] .. string.format('%q', fake_bin_dir .. '/forward.cancelled') .. [=[
exit 0
fi
if [[ "$ARGS" == *"-L"* ]]; then
if [[ "$ARGS" != *"ControlMaster"* ]]; then
echo "FAIL: Multiplexing flags missing!" >&2
exit 1
fi
if [[ "$ARGS" != *"sh -s"*"NVIM_READY"* ]]; then
echo "FAIL: tunnel script not fed to sh -s" >&2
exit 1
fi
printf '%s' "$ARGS" > "$ARGS_FILE"
]=] .. (behavior.tunnel or [=[
SOCK=$(echo "$ARGS" | grep -oE '\-L [^:]+' | cut -d' ' -f2)
sleep 60 &
SLEEP_PID=$!
echo "$$ $SLEEP_PID" > "$PID_FILE"
trap 'kill "$SLEEP_PID" 2>/dev/null || true; rm -f "$PID_FILE"; exit 0' TERM INT EXIT
echo "NVIM_READY"
touch "$SOCK"
wait "$SLEEP_PID"
]=]) .. [=[
fi
if [[ "$ARGS" == *"releases/download/"* ]]; then
printf '%s' "$ARGS" > ]=] .. string.format('%q', fake_bin_dir .. '/install.args') .. [=[
]=] .. (behavior.installer or [=[
echo "Installing Neovim..." >&2
exit 0
]=]) .. [=[
fi
if [[ "$ARGS" == *"-O"*"exit"* ]]; then
if [[ -f "$PID_FILE" ]]; then
set -- $(cat "$PID_FILE")
kill "$2" 2>/dev/null || true
kill "$1" 2>/dev/null || true
fi
exit 0
fi
echo "Unexpected SSH command: $ARGS" >&2
exit 1
]=])
end
t.write_file(fake_ssh_path, script)
vim.uv.fs_chmod(fake_ssh_path, 493)
local path_sep = is_windows and ';' or ':'
n.exec_lua(string.format(
[[
_G.orig_path = vim.fn.getenv('PATH')
vim.fn.setenv('PATH', %q .. %q .. _G.orig_path)
]],
fake_bin_dir,
path_sep
))
n.exec_lua(function(ssh_cmd, choice)
require('vim.net._ssh').ssh_cmd = ssh_cmd
_G.confirm_log = {}
vim.fn.confirm = function(...)
table.insert(_G.confirm_log, { ... })
return choice
end
end, { nvim_prog, '-l', fake_ssh, config }, behavior.confirm or 1)
end
local function teardown_fake_ssh()
if fake_bin_dir then
n.exec_lua([[
if _G.orig_path then
vim.fn.setenv('PATH', _G.orig_path)
end
]])
end
--- Checks that the fake tunnel process has exited.
local function tunnel_exited()
local pid = assert(tonumber(t.read_file(('%s/tunnel.pid'):format(fake_dir))))
return vim.uv.kill(pid, 0) ~= 0
end
before_each(function()
clear()
end)
after_each(function()
teardown_fake_ssh()
end)
describe('remote-ssh (Introspection)', function()
it('detects linux x86_64 successfully', function()
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
it('detects linux x86_64', function()
setup_fake_ssh({
uname = [[
echo "Linux"
echo "x86_64"
]],
uname = 'Linux\nx86_64\n',
})
local res = n.exec_lua([[
return { require('vim.net._ssh').get_system_info({ host = 'server' }) }
@@ -294,13 +168,9 @@ describe('vim.net._ssh', function()
eq('x86_64', res[2])
end)
it('detects macos arm64 successfully', function()
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
it('detects macos arm64', function()
setup_fake_ssh({
uname = [[
echo "Darwin"
echo "arm64"
]],
uname = 'Darwin\narm64\n',
})
local res = n.exec_lua([[
return { require('vim.net._ssh').get_system_info({ host = 'server' }) }
@@ -310,12 +180,8 @@ describe('vim.net._ssh', function()
end)
it('fails fast on Windows', function()
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
setup_fake_ssh({
uname = [[
echo "MSYS_NT-10.0-19045"
echo "x86_64"
]],
uname = 'MSYS_NT-10.0-19045\nx86_64\n',
})
local status, err = pcall(function()
n.exec_lua([[
@@ -329,78 +195,216 @@ describe('vim.net._ssh', function()
describe('remote-ssh (Orchestration)', function()
it('returns local socket with key-based auth', function()
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
skip(is_os('win'), 'remote-ssh is POSIX-only')
setup_fake_ssh()
local sock = n.exec_lua([[
local sock, _, teardown = require('vim.net._ssh').start('user@test-server')
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').start('user@test-server')
teardown()
return sock
]])
return { sock, logfile, progress }
]]))
assert(sock:match('_remote_nvim%.sock$'))
eq({
'running: Connecting to user@test-server...',
'running: Starting remote Nvim...',
'success: Connected to user@test-server',
}, progress)
local msg = 'This will install Nvim on 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 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.
setup_fake_ssh({ ssh_config = 'stricthostkeychecking yes\nserveraliveinterval 60\n' })
n.exec_lua(function()
local _, teardown = require('vim.net._ssh').start('user@test-server')
teardown()
end)
local explicit_args = t.read_file(fake_dir .. '/tunnel.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').start('user@test-server', 999)]])
)
eq(true, tunnel_exited())
eq(true, vim.uv.fs_stat(fake_dir .. '/forward.cancelled') ~= nil)
end)
it('installs the matching release privately and runs it', function()
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
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 release = n.exec_lua([[
local _, _, teardown = require('vim.net._ssh').start('user@test-server')
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').start('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').start('user@test-server')
teardown()
local v = vim.version()
local base = ('v%d.%d.%d'):format(v.major, v.minor, v.patch)
return v.prerelease and ('nightly-' .. base) or base
]])
local install = t.read_file(fake_bin_dir .. '/install.args')
local tag = release:match('^nightly') and 'nightly' or release
t.matches('releases/download/' .. vim.pesc(tag) .. '/nvim%-linux%-x86_64%.tar%.gz', install)
t.matches('dest="%$HOME"/%.local/share/nvim%-remote/releases/' .. vim.pesc(release), install)
eq(nil, install:find('.local/bin', 1, true))
t.matches(
'"%$HOME"/%.local/share/nvim%-remote/releases/'
.. vim.pesc(release)
.. '/bin/nvim %-%-headless',
t.read_file(fake_bin_dir .. '/tunnel.args')
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'
--- 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 = require('vim._core.system').run_wait(cmd, nil, function(stdout)
return stdout:match('NVIM_READY') ~= nil
end, 10000)
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')
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, obj.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')
)
end)
it('fails when ssh exits before the remote Nvim is ready', function()
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
setup_fake_ssh({
tunnel = [[
echo "channel 0: open failed: administratively prohibited" >&2
exit 255
]],
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').start('user@test-server')]])
)
eq(true, vim.uv.fs_stat(fake_bin_dir .. '/forward.cancelled') ~= nil)
eq('failed: Failed to connect to 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', function()
skip(is_os('win'), 'remote-ssh engine is POSIX-only')
it('fails when the remote Nvim crashes during startup, or is unreachable', function()
setup_fake_ssh({
tunnel = [[
echo "NVIM_CRASHED"
exit 1
]],
tunnel = { stdout = 'NVIM_CRASHED\n', exit = 1 },
})
t.matches(
'Remote Nvim crashed during startup',
t.pcall_err(n.exec_lua, [[require('vim.net._ssh').start('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').start('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 engine is POSIX-only')
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').start('user@test-server')
local _, teardown = require('vim.net._ssh').start('user@test-server')
teardown()
]])
local args = t.read_file(fake_bin_dir .. '/tunnel.args')
local args = t.read_file(fake_dir .. '/tunnel.args')
t.matches('mkdir %-m 700 "%$dir"', args)
return args:match(':(/tmp/nvim%-remote%.%x+)/nvim%.sock ')
end
@@ -409,5 +413,27 @@ describe('vim.net._ssh', function()
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'))
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)
end)
+11 -3
View File
@@ -140,21 +140,29 @@ describe('vim.system', function()
t.skip(t.is_os('win'), 'shell command is POSIX-only')
exec_lua(function()
local res
local res, exited
local ok, err = pcall(function()
res = require('vim._core.system').run_wait(
{ 'bash', '-c', 'echo READY; sleep 10' },
{ 'bash', '-c', 'echo READY; sleep 1; echo LATER' },
nil,
function(stdout)
return stdout:match('READY') ~= nil
end,
10000
10000,
function(out)
exited = out
end
)
assert(res)
assert(res.code == nil)
assert(res.stdout == 'READY\n')
assert(vim.api.nvim_get_proc(res.pid), 'process already exited')
-- Output after return (e.g. a heartbeat) is not captured.
assert(vim.wait(5000, function()
return exited ~= nil
end))
assert(exited.code == 0 and exited.stdout == nil)
end)
if res then
+25
View File
@@ -371,6 +371,31 @@ describe('vim.uri', function()
eq('root', uri.user)
eq('localhost', uri.host)
eq('2222', uri.port)
eq(
{ user = 'u', host = '::1', port = '22' },
exec_lua(function()
return require('vim.uri')._parse_ssh_uri('ssh://u@[::1]:22/home/u')
end)
)
eq(
{ host = 'host' },
exec_lua(function()
return require('vim.uri')._parse_ssh_uri('ssh://host/')
end)
)
eq(
{ user = 'first.last@corp', host = 'host' },
exec_lua(function()
return require('vim.uri')._parse_ssh_uri('ssh://first.last%40corp@host')
end)
)
eq(
'No host in SSH URI: ssh:///x',
t.pcall_err(exec_lua, function()
return require('vim.uri')._parse_ssh_uri('ssh:///x')
end)
)
end)
it('parses user@host', function()
+8
View File
@@ -214,6 +214,10 @@ describe('TUI :detach', function()
{ child_session:request('nvim_command', 'detach foo') }
)
-- XXX: Error if `current_ui` was set by (input from) a non-UI client.
child_session:request('nvim_input', '<Esc>:%detach<CR>')
screen:expect({ any = 'E5768: No UI attached' }) -- TODO(justinmk): confusing msg...
tt.feed_data('\027\027:detach\013')
-- Note: "Process exited" message is misleading; tt.setup_child_nvim() sees the foreground
-- process (client) exited, and doesn't know the server is still running?
@@ -1034,6 +1038,10 @@ describe('TUI :connect', function()
tt.feed_data(':connect\013')
screen1:expect({ any = 'E471: Argument required' })
-- No server at address: fails, UI stays attached (and "!" does not stop server).
tt.feed_data((':connect! %s\013'):format(new_pipename()))
screen1:expect({ any = 'E6102: Cannot connect to' })
tt.feed_data('iThis is server 1.\027')
screen1:expect({ any = vim.pesc('This is server 1^.') })