@@ -124,33 +124,68 @@ function M._handle_new_connection(server)
124124 -- Set up data handler
125125 client_tcp :read_start (function (err , data )
126126 if err then
127- server .on_error (" Client read error: " .. err )
128- M ._remove_client (server , client )
127+ local error_msg = " Client read error: " .. err
128+ server .on_error (error_msg )
129+ M ._disconnect_client (server , client , 1006 , error_msg )
129130 return
130131 end
131132
132133 if not data then
133134 -- EOF - client disconnected
134- M ._remove_client (server , client )
135+ M ._disconnect_client (server , client , 1006 , " EOF " )
135136 return
136137 end
137138
138139 -- Process incoming data
139140 client_manager .process_data (client , data , function (cl , message )
140141 server .on_message (cl , message )
141142 end , function (cl , code , reason )
142- server .on_disconnect (cl , code , reason )
143- M ._remove_client (server , cl )
143+ M ._disconnect_client (server , cl , code , reason )
144144 end , function (cl , error_msg )
145145 server .on_error (" Client " .. cl .id .. " error: " .. error_msg )
146- M ._remove_client (server , cl )
146+ M ._disconnect_client (server , cl , 1006 , " Client error: " .. error_msg )
147147 end , server .auth_token )
148148 end )
149149
150150 -- Notify about new connection
151151 server .on_connect (client )
152152end
153153
154+ --- Disconnect a client and remove it from the server.
155+ --- This ensures `server.on_disconnect` is invoked for every disconnect path
156+ --- (EOF, read errors, protocol errors, timeouts), and only once per client.
157+ --- @param server TCPServer The server object
158+ --- @param client WebSocketClient The client to disconnect
159+ --- @param code number | nil WebSocket close code
160+ --- @param reason string | nil WebSocket close reason
161+ function M ._disconnect_client (server , client , code , reason )
162+ assert (type (server ) == " table" , " Expected server to be a table" )
163+ local on_disconnect_type = type (server .on_disconnect )
164+ local on_disconnect_mt = on_disconnect_type == " table" and getmetatable (server .on_disconnect ) or nil
165+ assert (
166+ on_disconnect_type == " function" or (on_disconnect_mt ~= nil and type (on_disconnect_mt .__call ) == " function" ),
167+ " Expected server.on_disconnect to be callable"
168+ )
169+ assert (type (server .clients ) == " table" , " Expected server.clients to be a table" )
170+ assert (type (client ) == " table" , " Expected client to be a table" )
171+ assert (type (client .id ) == " string" , " Expected client.id to be a string" )
172+ if code ~= nil then
173+ assert (type (code ) == " number" , " Expected code to be a number" )
174+ end
175+ if reason ~= nil then
176+ assert (type (reason ) == " string" , " Expected reason to be a string" )
177+ end
178+
179+ -- Idempotency: a client can hit multiple disconnect paths (e.g. CLOSE frame
180+ -- followed by a TCP EOF). Only notify/remove once.
181+ if not server .clients [client .id ] then
182+ return
183+ end
184+
185+ server .on_disconnect (client , code , reason )
186+ M ._remove_client (server , client )
187+ end
188+
154189--- Remove a client from the server
155190--- @param server TCPServer The server object
156191--- @param client WebSocketClient The client to remove
@@ -293,7 +328,7 @@ function M.start_ping_timer(server, interval)
293328 string.format (" Client %s keepalive timeout (%ds idle), closing connection" , client .id , time_since_pong )
294329 )
295330 client_manager .close_client (client , 1006 , " Connection timeout" )
296- M ._remove_client (server , client )
331+ M ._disconnect_client (server , client , 1006 , " Connection timeout " )
297332 end
298333 end
299334 end
0 commit comments