Skip to content

[audit] server.lua: unbounded rpcrequest() in list_nvim_servers() can freeze the UI on a stalled remote socket #310

Description

@stanfish06

What

inspect_server() connects to every discovered nvim socket and issues a synchronous, un-timed-out vim.fn.rpcrequest() to read its label/cwd:

-- lua/config/server.lua:116-123
local connected, read, result = with_rpc_channel(path, function(chan)
    return vim.fn.rpcrequest(
        chan,
        "nvim_exec_lua",
        "local name = ...; return { vim.g[name] or false, vim.fn.getcwd() }",
        { SERVER_LABEL_VAR }
    )
end)

with_rpc_channel() (lua/config/server.lua:87-95) wraps sockconnect+the callback in pcall, but a pcall only catches errors — it does not bound time. rpcrequest() blocks the entire main loop (all UI redraws, all keystrokes) until the far end replies or the channel errors out.

Where

  • lua/config/server.lua:87-95with_rpc_channel(), no timeout around the RPC call
  • lua/config/server.lua:111-133inspect_server(), calls it once per discovered socket
  • lua/config/server.lua:135-160list_nvim_servers(), loops inspect_server() synchronously over every nvim.* socket under run_root(), including every locally-forwarded remote (forward_remote(), line 486-520)
  • lua/config/server.lua:259-266rename_server() has the identical un-timed-out rpcrequest pattern for the non-local-server branch
  • Callers that hit this on the main loop: :ShowNvimServers (line 647-652), :NvimHopopen_hop_pickerhop_entrieslist_nvim_servers (line 561-570), and the VimEnter autopick autocmd (line 689-706, runs on every plain nvim launch)

Why it matters

forward_remote() keeps an ssh -N -L tunnel alive as a local socket proxy for a remote nvim server. If the remote process hangs (suspended, OOM-thrashing, network partition on the far side of an already-established TCP connection), the local proxy socket still accepts the sockconnect — the hang only shows up once rpcrequest is waiting for a response that never comes. Since there's no timeout, Neovim's UI freezes completely (no redraws, no <C-c> cancel) until either the ssh ControlPersist connection notices the dead peer (can be minutes, depending on TCP keepalive/OS defaults) or something external kills the process.

This is user-triggered on every NvimHop/ShowNvimServers invocation, and silently on every fresh nvim launch via the VimEnter autopick path — so a single stale remote session can make even a brand-new local nvim appear to hang for no visible reason.

Recommended action

This needs a design decision (sync vs. async RPC, timeout duration, UX while waiting), so it's filed as an issue rather than a mechanical PR. Options:

  1. Bound it with vim.wait() + a request id, e.g. issue the RPC as rpcrequest from a coroutine (the repo already has lib/async.lua) and race it against a vim.defer_fn timeout that force-closes the channel (vim.fn.chanclose(chan)) and treats the server as dead — mirrors how list_nvim_servers() already treats a failed connect (cleanup_forward).
  2. Move discovery off the synchronous path: use vim.system/async channel APIs so list_nvim_servers() can be called with a callback instead of blocking, and have ShowNvimServers/NvimHop render a partial list that fills in as responses arrive.
  3. At minimum, apply the same timeout treatment to rename_server()'s rpcrequest call (line 259-266), which has the identical unbounded-wait shape.

Either way, a socket that fails to answer within a couple hundred ms should be treated the same as a dead socket (cleaned up via the existing cleanup_forward/os.remove paths) rather than left to hang the caller indefinitely.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions