-
Notifications
You must be signed in to change notification settings - Fork 187
Expand file tree
/
Copy pathtcp.lua
More file actions
358 lines (309 loc) · 11.6 KB
/
tcp.lua
File metadata and controls
358 lines (309 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
---@brief TCP server implementation using vim.loop
local client_manager = require("claudecode.server.client")
local M = {}
---@class TCPServer
---@field server table The vim.loop TCP server handle
---@field port number The port the server is listening on
---@field auth_token string|nil The authentication token for validating connections
---@field clients table<string, WebSocketClient> Table of connected clients
---@field on_message function Callback for WebSocket messages
---@field on_connect function Callback for new connections
---@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
---@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
function M.find_available_port(min_port, max_port)
assert(type(min_port) == "number", "Expected min_port to be a number")
assert(type(max_port) == "number", "Expected max_port to be a number")
min_port = math.floor(min_port)
max_port = math.floor(max_port)
if min_port > max_port then
return nil
end
local range_size = max_port - min_port + 1
assert(range_size >= 1, "Expected port range to be non-empty")
local start_offset = 0
if range_size > 1 then
-- Avoid `math.randomseed` here: it mutates global RNG state and is expensive
-- under coverage. We only need a pseudo-random starting point to avoid always
-- trying min_port first.
local uv = vim.loop
if uv and type(uv.hrtime) == "function" then
start_offset = tonumber(uv.hrtime() % range_size)
elseif uv and type(uv.now) == "function" then
start_offset = uv.now() % range_size
else
start_offset = os.time() % range_size
end
end
-- Try every port in the range exactly once, starting from a pseudo-random point.
for i = 0, range_size - 1 do
local port = min_port + ((start_offset + i) % range_size)
local test_server = vim.loop.new_tcp()
if test_server then
local success = test_server:bind("127.0.0.1", port)
test_server:close()
if success then
return port
end
end
end
return 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
-- Create server object
local server = {
server = tcp_server,
port = port,
auth_token = auth_token,
clients = {},
on_message = callbacks.on_message or function() end,
on_connect = callbacks.on_connect or function() end,
on_disconnect = callbacks.on_disconnect or function() end,
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
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")
end
return server, nil
end
---Handle a new client connection
---@param server TCPServer The server object
function M._handle_new_connection(server)
local client_tcp = vim.loop.new_tcp()
if not client_tcp then
server.on_error("Failed to create client TCP handle")
return
end
local accept_success, accept_err = server.server:accept(client_tcp)
if not accept_success then
server.on_error("Failed to accept connection: " .. (accept_err or "unknown error"))
client_tcp:close()
return
end
-- Create WebSocket client wrapper
local client = client_manager.create_client(client_tcp)
server.clients[client.id] = client
-- Set up data handler
client_tcp:read_start(function(err, data)
if err then
local error_msg = "Client read error: " .. err
server.on_error(error_msg)
M._disconnect_client(server, client, 1006, error_msg)
return
end
if not data then
-- EOF - client disconnected
M._disconnect_client(server, client, 1006, "EOF")
return
end
-- Process incoming data
client_manager.process_data(client, data, function(cl, message)
server.on_message(cl, message)
end, function(cl, code, reason)
M._disconnect_client(server, cl, code, reason)
end, function(cl, error_msg)
server.on_error("Client " .. cl.id .. " error: " .. error_msg)
M._disconnect_client(server, cl, 1006, "Client error: " .. error_msg)
end, server.auth_token)
end)
-- Notify about new connection
server.on_connect(client)
end
---Disconnect a client and remove it from the server.
---This ensures `server.on_disconnect` is invoked for every disconnect path
---(EOF, read errors, protocol errors, timeouts), and only once per client.
---@param server TCPServer The server object
---@param client WebSocketClient The client to disconnect
---@param code number|nil WebSocket close code
---@param reason string|nil WebSocket close reason
function M._disconnect_client(server, client, code, reason)
assert(type(server) == "table", "Expected server to be a table")
local on_disconnect_type = type(server.on_disconnect)
local on_disconnect_mt = on_disconnect_type == "table" and getmetatable(server.on_disconnect) or nil
assert(
on_disconnect_type == "function" or (on_disconnect_mt ~= nil and type(on_disconnect_mt.__call) == "function"),
"Expected server.on_disconnect to be callable"
)
assert(type(server.clients) == "table", "Expected server.clients to be a table")
assert(type(client) == "table", "Expected client to be a table")
assert(type(client.id) == "string", "Expected client.id to be a string")
if code ~= nil then
assert(type(code) == "number", "Expected code to be a number")
end
if reason ~= nil then
assert(type(reason) == "string", "Expected reason to be a string")
end
-- Idempotency: a client can hit multiple disconnect paths (e.g. CLOSE frame
-- followed by a TCP EOF). Only notify/remove once.
if not server.clients[client.id] then
return
end
server.on_disconnect(client, code, reason)
M._remove_client(server, client)
end
---Remove a client from the server
---@param server TCPServer The server object
---@param client WebSocketClient The client to remove
function M._remove_client(server, client)
if server.clients[client.id] then
server.clients[client.id] = nil
if not client.tcp_handle:is_closing() then
client.tcp_handle:close()
end
end
end
---Send a message to a specific client
---@param server TCPServer The server object
---@param client_id string The client ID
---@param message string The message to send
---@param callback function|nil Optional callback
function M.send_to_client(server, client_id, message, callback)
local client = server.clients[client_id]
if not client then
if callback then
callback("Client not found: " .. client_id)
end
return
end
client_manager.send_message(client, message, callback)
end
---Broadcast a message to all connected clients
---@param server TCPServer The server object
---@param message string The message to broadcast
function M.broadcast(server, message)
for _, client in pairs(server.clients) do
client_manager.send_message(client, message)
end
end
---Get the number of connected clients
---@param server TCPServer The server object
---@return number count Number of connected clients
function M.get_client_count(server)
local count = 0
for _ in pairs(server.clients) do
count = count + 1
end
return count
end
---Get information about all clients
---@param server TCPServer The server object
---@return table clients Array of client information
function M.get_clients_info(server)
local clients = {}
for _, client in pairs(server.clients) do
table.insert(clients, client_manager.get_client_info(client))
end
return clients
end
---Close a specific client connection
---@param server TCPServer The server object
---@param client_id string The client ID
---@param code number|nil Close code
---@param reason string|nil Close reason
function M.close_client(server, client_id, code, reason)
local client = server.clients[client_id]
if client then
client_manager.close_client(client, code, reason)
end
end
---Stop the TCP server
---@param server TCPServer The server object
function M.stop_server(server)
-- Close all clients
for _, client in pairs(server.clients) do
client_manager.close_client(client, 1001, "Server shutting down")
end
-- Clear clients
server.clients = {}
-- Close server
if server.server and not server.server:is_closing() then
server.server:close()
end
end
---Start a periodic ping task to keep connections alive
---@param server TCPServer The server object
---@param interval number Ping interval in milliseconds (default: 30000)
---@return table? timer The timer handle, or nil if creation failed
function M.start_ping_timer(server, interval)
interval = interval or 30000 -- 30 seconds
local last_run = vim.loop.now()
local timer = vim.loop.new_timer()
if not timer then
server.on_error("Failed to create ping timer")
return nil
end
timer:start(interval, interval, function()
local now = vim.loop.now()
local elapsed = now - last_run
-- Detect potential system sleep: timer interval was significantly exceeded
-- Allow 50% grace period (e.g., 45s instead of 30s) to account for system load
local is_wake_from_sleep = elapsed > (interval * 1.5)
if is_wake_from_sleep then
-- After system sleep/wake, reset all client pong timestamps to prevent false timeouts
-- This gives clients a fresh keepalive window since the time jump isn't their fault
require("claudecode.logger").debug(
"server",
string.format(
"Detected potential wake from sleep (%.1fs elapsed), resetting client keepalive timers",
elapsed / 1000
)
)
for _, client in pairs(server.clients) do
if client.state == "connected" then
client.last_pong = now
end
end
end
for _, client in pairs(server.clients) do
if client.state == "connected" then
-- Check if client is alive (local connections, so use standard timeout)
if client_manager.is_client_alive(client, interval * 2) then
client_manager.send_ping(client, "ping")
else
-- Client connection timed out - log at INFO level (this is expected behavior)
local time_since_pong = math.floor((now - client.last_pong) / 1000)
require("claudecode.logger").info(
"server",
string.format("Client %s keepalive timeout (%ds idle), closing connection", client.id, time_since_pong)
)
client_manager.close_client(client, 1006, "Connection timeout")
M._disconnect_client(server, client, 1006, "Connection timeout")
end
end
end
last_run = now
end)
return timer
end
return M