@@ -127,36 +127,72 @@ function M._handle_new_connection(server)
127127 if err then
128128 -- ECONNRESET, EOF, EPIPE are expected when terminal closes - don't treat as errors
129129 if err :match (" ECONNRESET" ) or err :match (" EOF" ) or err :match (" EPIPE" ) then
130- server .on_disconnect_cleanup (client , err )
130+ if server .on_disconnect_cleanup then
131+ server .on_disconnect_cleanup (client , err )
132+ end
131133 else
132134 server .on_error (" Client read error: " .. err )
133135 end
134- M ._remove_client (server , client )
136+ M ._disconnect_client (server , client , 1006 , " Client read error: " .. err )
135137 return
136138 end
137139
138140 if not data then
139141 -- EOF - client disconnected
140- M ._remove_client (server , client )
142+ M ._disconnect_client (server , client , 1006 , " EOF " )
141143 return
142144 end
143145
144146 -- Process incoming data
145147 client_manager .process_data (client , data , function (cl , message )
146148 server .on_message (cl , message )
147149 end , function (cl , code , reason )
148- server .on_disconnect (cl , code , reason )
149- M ._remove_client (server , cl )
150+ M ._disconnect_client (server , cl , code , reason )
150151 end , function (cl , error_msg )
151152 server .on_error (" Client " .. cl .id .. " error: " .. error_msg )
152- M ._remove_client (server , cl )
153+ M ._disconnect_client (server , cl , 1006 , " Client error: " .. error_msg )
153154 end , server .auth_token )
154155 end )
155156
156157 -- Notify about new connection
157158 server .on_connect (client )
158159end
159160
161+ --- Disconnect a client and remove it from the server.
162+ --- This ensures `server.on_disconnect` is invoked for every disconnect path
163+ --- (EOF, read errors, protocol errors, timeouts), and only once per client.
164+ --- @param server TCPServer The server object
165+ --- @param client WebSocketClient The client to disconnect
166+ --- @param code number | nil WebSocket close code
167+ --- @param reason string | nil WebSocket close reason
168+ function M ._disconnect_client (server , client , code , reason )
169+ assert (type (server ) == " table" , " Expected server to be a table" )
170+ local on_disconnect_type = type (server .on_disconnect )
171+ local on_disconnect_mt = on_disconnect_type == " table" and getmetatable (server .on_disconnect ) or nil
172+ assert (
173+ on_disconnect_type == " function" or (on_disconnect_mt ~= nil and type (on_disconnect_mt .__call ) == " function" ),
174+ " Expected server.on_disconnect to be callable"
175+ )
176+ assert (type (server .clients ) == " table" , " Expected server.clients to be a table" )
177+ assert (type (client ) == " table" , " Expected client to be a table" )
178+ assert (type (client .id ) == " string" , " Expected client.id to be a string" )
179+ if code ~= nil then
180+ assert (type (code ) == " number" , " Expected code to be a number" )
181+ end
182+ if reason ~= nil then
183+ assert (type (reason ) == " string" , " Expected reason to be a string" )
184+ end
185+
186+ -- Idempotency: a client can hit multiple disconnect paths (e.g. CLOSE frame
187+ -- followed by a TCP EOF). Only notify/remove once.
188+ if not server .clients [client .id ] then
189+ return
190+ end
191+
192+ server .on_disconnect (client , code , reason )
193+ M ._remove_client (server , client )
194+ end
195+
160196--- Remove a client from the server
161197--- @param server TCPServer The server object
162198--- @param client WebSocketClient The client to remove
@@ -299,7 +335,7 @@ function M.start_ping_timer(server, interval)
299335 string.format (" Client %s keepalive timeout (%ds idle), closing connection" , client .id , time_since_pong )
300336 )
301337 client_manager .close_client (client , 1006 , " Connection timeout" )
302- M ._remove_client (server , client )
338+ M ._disconnect_client (server , client , 1006 , " Connection timeout " )
303339 end
304340 end
305341 end
0 commit comments