Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions fixtures/issue-283/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
-- Fixture for issue #283:
-- "find_available_port probe-then-rebind races; create_server has no retry ->
-- EADDRINUSE with parallel Neovim instances (regression in #282)"
-- https://github.com/coder/claudecode.nvim/issues/283
--
-- This fixture starts the REAL claudecode WebSocket server on launch and prints
-- a big banner showing whether THIS instance got a listening port or failed.
--
-- Reproduction (from repo root), in TWO terminals:
-- source fixtures/nvim-aliases.sh
-- vv issue-283 # terminal 1 -> "LISTENING on port 48811"
-- vv issue-283 # terminal 2 -> "FAILED ... Failed to listen on port 48811: EADDRINUSE"
--
-- Because #282 dropped the per-process RNG seeding, every fresh Neovim picks the
-- SAME port (48811 with the default 10000-65535 range), so the second instance
-- always collides. The probe in find_available_port cannot notice the first
-- instance's listener (libuv defers EADDRINUSE to listen()), and create_server
-- does not retry, so the integration never starts in instance 2.
--
-- :ReproStatus re-print this instance's server status
-- :ReproStop stop this instance's server (frees the port / lockfile)

local config_dir = vim.fn.stdpath("config")
local repo_root = vim.fn.fnamemodify(config_dir, ":h:h")
vim.opt.rtp:prepend(repo_root)

vim.g.mapleader = " "
vim.g.maplocalleader = "\\"
vim.o.showtabline = 0
vim.o.laststatus = 2

local ok, claudecode = pcall(require, "claudecode")
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))

-- auto_start = false so we can call start() ourselves and capture its result.
claudecode.setup({
auto_start = false,
log_level = "info",
terminal = {
provider = "native",
auto_close = false,
},
})

local started_ok, started_info = claudecode.start(false)

local function status_lines()
local running = claudecode.state and claudecode.state.server ~= nil
local port = claudecode.state and claudecode.state.port or nil
local lines = {
"claudecode.nvim -- issue #283 reproduction fixture",
"",
"Run `vv issue-283` in a SECOND terminal while this one is open.",
"",
}
if started_ok and running then
lines[#lines + 1] = "THIS INSTANCE: ✅ server LISTENING on port " .. tostring(port)
lines[#lines + 1] = ""
lines[#lines + 1] = "Now open a second instance: it should FAIL on the same port"
lines[#lines + 1] = "with EADDRINUSE, because every fresh Neovim deterministically"
lines[#lines + 1] = "selects this same port (lost RNG seeding in #282)."
else
lines[#lines + 1] = "THIS INSTANCE: ❌ server FAILED to start"
lines[#lines + 1] = ""
lines[#lines + 1] = " " .. tostring(started_info)
lines[#lines + 1] = ""
lines[#lines + 1] = "This is #283: another Neovim already holds this port, the probe"
lines[#lines + 1] = "could not detect it, and create_server did not retry."
end
lines[#lines + 1] = ""
lines[#lines + 1] = ":ReproStatus re-print status :ReproStop stop this server"
return lines, (started_ok and running)
end

local function show_banner()
local lines, good = status_lines()
vim.bo.modifiable = true
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
vim.bo.modifiable = false
vim.bo.modified = false
-- Keep the echo SHORT (port only) so it stays below the hit-enter threshold;
-- the full error text lives in the banner buffer above.
local msg = good and ("issue283: LISTENING on port " .. tostring(claudecode.state.port))
or "issue283: FAILED -- port in use (EADDRINUSE); see buffer above"
vim.api.nvim_echo({ { msg, good and "MoreMsg" or "ErrorMsg" } }, false, {})
end

vim.api.nvim_create_user_command("ReproStatus", show_banner, { desc = "Re-print #283 server status" })
vim.api.nvim_create_user_command("ReproStop", function()
claudecode.stop()
vim.api.nvim_echo({ { "issue283: server stopped", "MoreMsg" } }, false, {})
end, { desc = "Stop this instance's server (#283)" })

-- Populate the buffer synchronously at load time so it is already non-empty when
-- startup finishes -- this suppresses Neovim's intro screen without depending on
-- a deferred redraw (a hit-enter prompt from the plugin's own error log can
-- otherwise block a scheduled callback). The plugin's native error message still
-- appears in the message area, exactly as a real user sees it.
show_banner()
155 changes: 115 additions & 40 deletions lua/claudecode/server/tcp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,58 @@ local M = {}
---@field on_disconnect function Callback for client disconnections
---@field on_error fun(err_msg: string) Callback for errors

---Find an available port by attempting to bind
-- Seed Lua's PRNG exactly once per process. #282 removed the implicit seeding
-- that used to happen via utils.shuffle_array (math.randomseed(os.time())), which
-- left LuaJIT's fixed default seed in place -- so every fresh Neovim picked the
-- *same* starting port and parallel instances always collided (#283). Mixing in a
-- sub-second source avoids two instances launched in the same second seeding
-- identically. hrtime is guarded because some test stubs omit it.
local rng_seeded = false
local function ensure_rng_seeded()
if rng_seeded then
return
end
local jitter
local ok, hr = pcall(function()
return vim.loop and vim.loop.hrtime and vim.loop.hrtime()
end)
if ok and type(hr) == "number" then
jitter = hr % 1000000
else
jitter = math.floor((os.clock() % 1) * 1000000)
end
math.randomseed((os.time() * 1000000) + jitter)
rng_seeded = true
end

-- Iterate the port range exactly once, starting from a random offset and wrapping
-- around. Returns a closure rather than materializing the range: the default
-- 10000-65535 range is ~55k entries, and building/shuffling it on every startup
-- was the cost #282 set out to remove.
local function port_iterator(min_port, max_port)
local port_count = max_port - min_port + 1
if port_count <= 0 then
return function()
return nil
end
end
ensure_rng_seeded()
local start_offset = math.random(port_count) - 1
local checked = -1
return function()
checked = checked + 1
if checked >= port_count then
return nil
end
return min_port + ((start_offset + checked) % port_count)
end
end

---Find an available port using a best-effort bind probe.
---NOTE: this is only a pre-filter. A successful throwaway bind does NOT guarantee
---the port is free: libuv's bind() defers EADDRINUSE to listen()/connect(), so a
---port another process is actively listening on still passes this probe. The
---authoritative check is create_server's bind+listen with retry.
---@param min_port number Minimum port to try
---@param max_port number Maximum port to try
---@return number|nil port Available port number, or nil if none found
Expand All @@ -25,14 +76,7 @@ function M.find_available_port(min_port, max_port)
return nil
end

local port_count = max_port - min_port + 1
local start_offset = math.random(port_count) - 1

-- Pick a random starting point, then scan the range once. This keeps the
-- selection spread across the configured range without building and shuffling
-- a 55k-entry table for the default 10000-65535 range on every startup.
for checked = 0, port_count - 1 do
local port = min_port + ((start_offset + checked) % port_count)
for port in port_iterator(min_port, max_port) do
local test_server = vim.loop.new_tcp()
if test_server then
local success = test_server:bind("127.0.0.1", port)
Expand All @@ -47,27 +91,58 @@ function M.find_available_port(min_port, max_port)
return nil
end

---Bind AND listen on a single fresh TCP handle, returning that same handle.
---Binding then listening on one socket (instead of probing a throwaway socket and
---re-binding) is what makes a busy port detectable: libuv's bind() defers
---EADDRINUSE to listen(), so the listen() call is the real test, and keeping the
---handle we listened on removes the probe/rebind TOCTOU window.
---@param server TCPServer The server object whose connection handler to wire up
---@param port number Port to bind and listen on
---@return table|nil handle The bound+listening TCP handle, or nil on failure
---@return string|nil error Error message if failed
function M._bind_and_listen(server, port)
local handle = vim.loop.new_tcp()
if not handle then
return nil, "Failed to create TCP server"
end

local bind_success, bind_err = handle:bind("127.0.0.1", port)
if not bind_success then
handle:close()
return nil, "Failed to bind to port " .. port .. ": " .. (bind_err or "unknown error")
end

local listen_success, listen_err = handle:listen(128, function(err)
if err then
server.on_error("Listen error: " .. err)
return
end

M._handle_new_connection(server)
end)

if not listen_success then
handle:close()
return nil, "Failed to listen on port " .. port .. ": " .. (listen_err or "unknown error")
end

return handle, nil
end

---Create and start a TCP server
---@param config ClaudeCodeConfig Server configuration
---@param callbacks table Callback functions
---@param auth_token string|nil Authentication token for validating connections
---@return TCPServer|nil server The server object, or nil on error
---@return string|nil error Error message if failed
function M.create_server(config, callbacks, auth_token)
local port = M.find_available_port(config.port_range.min, config.port_range.max)
if not port then
return nil, "No available ports in range " .. config.port_range.min .. "-" .. config.port_range.max
end

local tcp_server = vim.loop.new_tcp()
if not tcp_server then
return nil, "Failed to create TCP server"
end
local min_port = config.port_range.min
local max_port = config.port_range.max

-- Create server object
-- Build the server object up front so the listen callback can close over it.
local server = {
server = tcp_server,
port = port,
server = nil,
port = nil,
auth_token = auth_token,
clients = {},
on_message = callbacks.on_message or function() end,
Expand All @@ -76,28 +151,28 @@ function M.create_server(config, callbacks, auth_token)
on_error = callbacks.on_error or function() end,
}

local bind_success, bind_err = tcp_server:bind("127.0.0.1", port)
if not bind_success then
tcp_server:close()
return nil, "Failed to bind to port " .. port .. ": " .. (bind_err or "unknown error")
end

-- Start listening
local listen_success, listen_err = tcp_server:listen(128, function(err)
if err then
callbacks.on_error("Listen error: " .. err)
return
-- Walk candidate ports and bind+listen on each until one succeeds. Retrying
-- here (rather than committing to a single pre-probed port) is what fixes #283:
-- when several Neovim instances race for the same port, the losers just advance
-- to the next candidate instead of giving up with EADDRINUSE.
local last_err
local tried_any = false
for port in port_iterator(min_port, max_port) do
tried_any = true
local handle, err = M._bind_and_listen(server, port)
if handle then
server.server = handle
server.port = port
return server, nil
end

M._handle_new_connection(server)
end)

if not listen_success then
tcp_server:close()
return nil, "Failed to listen on port " .. port .. ": " .. (listen_err or "unknown error")
last_err = err
end

return server, nil
if not tried_any then
return nil, "No available ports in range " .. min_port .. "-" .. max_port
end
return nil,
"Failed to bind to any port in range " .. min_port .. "-" .. max_port .. ": " .. (last_err or "unknown error")
end

---Handle a new client connection
Expand Down
19 changes: 0 additions & 19 deletions lua/claudecode/server/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -386,25 +386,6 @@ function M.apply_mask(data, mask)
return table.concat(result)
end

local rng_seeded = false

---Shuffle an array in place using Fisher-Yates algorithm
---@param tbl table The array to shuffle
function M.shuffle_array(tbl)
-- Seed the PRNG once per process so port selection order varies across editor
-- starts. Seeding lazily on first use (rather than on every call, as a prior
-- version did with os.time()) avoids identical orderings within the same
-- second while still giving each process a distinct sequence.
if not rng_seeded then
math.randomseed(os.time())
rng_seeded = true
end
for i = #tbl, 2, -1 do
local j = math.random(i)
tbl[i], tbl[j] = tbl[j], tbl[i]
end
end

---Compare two strings in constant time relative to their length.
---Returns false immediately on a length mismatch; otherwise every byte is
---examined so total work does not depend on the matching-prefix length.
Expand Down
Loading
Loading