Skip to content
Open
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
20 changes: 20 additions & 0 deletions lua/claudecode/server/tcp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ local client_manager = require("claudecode.server.client")

local M = {}

-- libuv error names that just mean the peer went away, as opposed to a real read
-- failure. See _handle_new_connection.
local PEER_DISCONNECT_ERRORS = {
ECONNRESET = true,
ECONNABORTED = true,
}

---@class TCPServer
---@field server table The vim.loop TCP server handle
---@field port number The port the server is listening on
Expand Down Expand Up @@ -198,6 +205,19 @@ function M._handle_new_connection(server)
-- Set up data handler
client_tcp:read_start(function(err, data)
if err then
-- A client that dies without sending a Close frame (killed CLI, closed
-- terminal, suspended machine) surfaces here as a read error. That is a normal
-- disconnect, so it must not go through on_error: the notify(ERROR) it raises
-- interrupts the user with a hit-enter prompt every time a broadcast probes the
-- dead socket (#316).
-- Stream callbacks hand us the bare error name, but luv also formats errors as
-- "NAME: message", so key off the leading name and keep the whole string as the
-- disconnect reason.
if PEER_DISCONNECT_ERRORS[err:match("^([^:]+)")] then
M._disconnect_client(server, client, 1006, err)
return
end

local error_msg = "Client read error: " .. err
server.on_error(error_msg)
M._disconnect_client(server, client, 1006, error_msg)
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/server/tcp_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,68 @@ describe("TCP server disconnect handling", function()
expect(server.clients[client.id]).to_be_nil()
end)

-- A client that dies without a Close frame shows up as a read error, but it is a
-- normal disconnect and must not reach on_error (#316). Stream callbacks currently
-- pass the bare error name; luv also formats errors as "NAME: message".
for _, err in ipairs({
"ECONNRESET",
"ECONNRESET: connection reset by peer",
"ECONNABORTED",
"ECONNABORTED: software caused connection abort",
}) do
it("should disconnect without on_error when the peer resets the connection (" .. err .. ")", function()
local callbacks = {
on_message = spy.new(function() end),
on_connect = spy.new(function() end),
on_disconnect = spy.new(function() end),
on_error = spy.new(function() end),
}

local config = { port_range = { min = 10000, max = 10000 } }
local server, create_err = tcp.create_server(config, callbacks, nil)
assert.is_nil(create_err)
assert.is_table(server)

tcp._handle_new_connection(server)

local client = callbacks.on_connect.calls[1].vals[1]
client.tcp_handle._read_cb(err, nil)

assert.spy(callbacks.on_disconnect).was_called(1)
assert.spy(callbacks.on_disconnect).was_called_with(client, 1006, err)
expect(server.clients[client.id]).to_be_nil()

assert.spy(callbacks.on_error).was_not_called()
end)
end

-- Codes that are not a peer disconnect must keep being reported, in both formats.
for _, err in ipairs({ "EPIPE", "ENOTCONN", "ETIMEDOUT", "EHOSTUNREACH: no route to host" }) do
it("should still report a genuine read error (" .. err .. ")", function()
local callbacks = {
on_message = spy.new(function() end),
on_connect = spy.new(function() end),
on_disconnect = spy.new(function() end),
on_error = spy.new(function() end),
}

local config = { port_range = { min = 10000, max = 10000 } }
local server, create_err = tcp.create_server(config, callbacks, nil)
assert.is_nil(create_err)
assert.is_table(server)

tcp._handle_new_connection(server)

local client = callbacks.on_connect.calls[1].vals[1]
client.tcp_handle._read_cb(err, nil)

assert.spy(callbacks.on_error).was_called(1)
assert.spy(callbacks.on_error).was_called_with("Client read error: " .. err)
assert.spy(callbacks.on_disconnect).was_called(1)
expect(server.clients[client.id]).to_be_nil()
end)
end

it("should call on_disconnect and remove client on TCP read error", function()
local callbacks = {
on_message = spy.new(function() end),
Expand Down