From bbd036939e1ada3e4e1f1ea72c9ba1038d82c221 Mon Sep 17 00:00:00 2001 From: Andrea Rossi Date: Fri, 16 Oct 2026 10:40:07 +0100 Subject: [PATCH] fix(server): don't close the handle twice after a failed handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client that hangs up as soon as it reads the 400, like a local port scanner probing for web servers, has its handle closed by the EOF teardown before the scheduled close runs, which then raises "handle … is already closing". --- lua/claudecode/server/client.lua | 6 ++- tests/unit/server/client_spec.lua | 79 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/unit/server/client_spec.lua diff --git a/lua/claudecode/server/client.lua b/lua/claudecode/server/client.lua index 9de3fa25..11b2fb88 100644 --- a/lua/claudecode/server/client.lua +++ b/lua/claudecode/server/client.lua @@ -105,7 +105,11 @@ function M.process_data(client, data, on_message, on_close, on_error, auth_token client.state = "closing" logger.debug("client", "Closing connection for client due to failed handshake:", client.id) vim.schedule(function() - client.tcp_handle:close() + -- The peer usually hangs up as soon as it reads the error response, and + -- tcp.lua's EOF teardown closes the handle before this runs. + if not client.tcp_handle:is_closing() then + client.tcp_handle:close() + end end) end end) diff --git a/tests/unit/server/client_spec.lua b/tests/unit/server/client_spec.lua new file mode 100644 index 00000000..cb2bacd8 --- /dev/null +++ b/tests/unit/server/client_spec.lua @@ -0,0 +1,79 @@ +require("tests.busted_setup") + +local client = require("claudecode.server.client") + +describe("WebSocket client failed handshake", function() + -- A plain HTTP request, the way a local port scanner probes for web servers. + local PLAIN_GET = "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nUser-Agent: Go-http-client/1.1\r\nConnection: close\r\n\r\n" + + local original_schedule + local scheduled + + before_each(function() + scheduled = {} + original_schedule = vim.schedule + vim.schedule = function(fn) + table.insert(scheduled, fn) + end + end) + + after_each(function() + vim.schedule = original_schedule + end) + + -- Behaves like a libuv TCP handle: closing one that is already closing raises. + local function make_handle() + local handle = { closes = 0, writes = {} } + function handle:write(data, callback) + table.insert(self.writes, data) + if callback then + callback() + end + return true + end + function handle:close() + if self.closes > 0 then + error("handle " .. tostring(self) .. " is already closing") + end + self.closes = self.closes + 1 + end + function handle:is_closing() + return self.closes > 0 + end + return handle + end + + local function noop() end + + local function run_scheduled() + for _, fn in ipairs(scheduled) do + fn() + end + end + + it("does not close the handle again when the peer hung up first", function() + local handle = make_handle() + local c = client.create_client(handle) + + client.process_data(c, PLAIN_GET, noop, noop, noop, nil) + assert.matches("^HTTP/1.1 400", handle.writes[1]) + + -- The peer reads the 400 and hangs up; tcp.lua's EOF teardown closes the handle + -- before the scheduled close runs. + handle:close() + + assert.has_no.errors(run_scheduled) + assert.equals(1, handle.closes) + end) + + it("closes the handle when the peer keeps the connection open", function() + local handle = make_handle() + local c = client.create_client(handle) + + client.process_data(c, PLAIN_GET, noop, noop, noop, nil) + run_scheduled() + + assert.equals(1, handle.closes) + assert.equals("closing", c.state) + end) +end)