mirror of
https://github.com/neovim/neovim.git
synced 2026-11-06 05:16:52 +00:00
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:
1 parent
b94cdb848e
commit
62f79bd7e1
23 files changed
+865
-604
No files matched your search
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+4
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
]=],
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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^.') })
|
||||
|
||||
|
||||
Reference in new issue
Block a user