From be572e73a24a799ef164ea28b1f551f8fcce3654 Mon Sep 17 00:00:00 2001 From: Andrei Loginov Date: Tue, 1 Sep 2026 19:20:01 -0300 Subject: [PATCH] fix(server): treat peer resets as disconnects, not server errors A client that goes away without sending a Close frame (killed CLI, closed terminal, suspended machine) makes libuv report ECONNRESET on the next read. tcp.lua escalated every read error to server.on_error, which logs at ERROR level and therefore raises a vim.notify(ERROR). Selection changes are broadcast to every connected client, so each selection that probed a dead socket interrupted the user with a hit-enter prompt, once per stale client. The disconnect itself was already handled correctly on the same branch, so the notification was pure noise. Handle ECONNRESET and ECONNABORTED like EOF: disconnect the client with 1006 and skip on_error. Every other read error still surfaces as before. Stream callbacks pass the bare error name, but luv also formats errors as "NAME: message", so match on the leading name and keep the whole string as the disconnect reason. Fixes #316 --- lua/claudecode/server/tcp.lua | 20 +++++++++++ tests/unit/server/tcp_spec.lua | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/lua/claudecode/server/tcp.lua b/lua/claudecode/server/tcp.lua index a33de204..d7002550 100644 --- a/lua/claudecode/server/tcp.lua +++ b/lua/claudecode/server/tcp.lua @@ -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 @@ -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) diff --git a/tests/unit/server/tcp_spec.lua b/tests/unit/server/tcp_spec.lua index a6b42563..da74d1db 100644 --- a/tests/unit/server/tcp_spec.lua +++ b/tests/unit/server/tcp_spec.lua @@ -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),