@@ -114,10 +114,33 @@ function M.process_data(client, data, on_message, on_close, on_error, auth_token
114114 end
115115
116116 while # client .buffer >= 2 do -- Minimum frame size
117- local parsed_frame , bytes_consumed = frame .parse_frame (client .buffer )
117+ -- Stop if a prior frame (or a TCP error) already initiated teardown. The
118+ -- handle can still be open (Close-frame write / scheduled on_close/on_error
119+ -- pending) and read is not stopped, so a later TCP segment could otherwise
120+ -- re-enter process_data and dispatch frames against a closing/closed client.
121+ if client .state == " closing" or client .state == " closed" then
122+ break
123+ end
124+
125+ local parsed_frame , bytes_consumed , close_code = frame .parse_frame (client .buffer )
118126
119127 if not parsed_frame then
120- break
128+ if close_code then
129+ -- Fatal protocol violation: send the RFC 6455 Close frame, drop the
130+ -- offending bytes (so they are not re-parsed forever, which previously
131+ -- wedged the connection), and tear the connection down.
132+ --
133+ -- on_error runs the synchronous teardown (tcp.lua _disconnect_client ->
134+ -- _remove_client -> tcp_handle:close()), which would cancel the pending
135+ -- Close-frame write if it ran first. Passing it as close_client's on_done
136+ -- runs it from the write callback, i.e. only after the status code has
137+ -- been flushed, so the peer actually receives the 1002/1007/1009 close.
138+ client .buffer = " "
139+ M .close_client (client , close_code , " Protocol error" , function ()
140+ on_error (client , " WebSocket protocol error in frame" )
141+ end )
142+ end
143+ break -- No close_code means the frame is incomplete; wait for more bytes.
121144 end
122145
123146 -- Frame validation is now handled entirely within frame.parse_frame.
@@ -155,18 +178,35 @@ function M.process_data(client, data, on_message, on_close, on_error, auth_token
155178 vim .schedule (function ()
156179 on_close (client , code , reason )
157180 end )
181+ -- A CLOSE is terminal: drop any bytes that followed it (a frame after CLOSE
182+ -- is a protocol violation) and stop iterating, so we neither echo a PONG
183+ -- after our own Close nor dispatch on_message after on_close. Matches the
184+ -- other termination branches.
185+ client .buffer = " "
186+ break
158187 elseif parsed_frame .opcode == frame .OPCODE .PING then
159188 local pong_frame = frame .create_pong_frame (parsed_frame .payload )
160189 client .tcp_handle :write (pong_frame )
161190 elseif parsed_frame .opcode == frame .OPCODE .PONG then
162191 client .last_pong = vim .loop .now ()
163192 elseif parsed_frame .opcode == frame .OPCODE .CONTINUATION then
164- -- Continuation frame - for simplicity, we don't support fragmentation
165- on_error (client , " Fragmented messages not supported" )
166- M .close_client (client , 1003 , " Unsupported data" )
193+ -- Continuation frame - for simplicity, we don't support fragmentation.
194+ -- Run on_error from close_client's write callback (so the Close frame is
195+ -- flushed before teardown), then clear the buffer and break so we stop
196+ -- iterating over a connection that is being torn down. See the fatal-frame
197+ -- branch above for the rationale.
198+ M .close_client (client , 1003 , " Unsupported data" , function ()
199+ on_error (client , " Fragmented messages not supported" )
200+ end )
201+ client .buffer = " "
202+ break
167203 else
168- on_error (client , " Unknown WebSocket opcode: " .. parsed_frame .opcode )
169- M .close_client (client , 1002 , " Protocol error" )
204+ local opcode = parsed_frame .opcode
205+ M .close_client (client , 1002 , " Protocol error" , function ()
206+ on_error (client , " Unknown WebSocket opcode: " .. opcode )
207+ end )
208+ client .buffer = " "
209+ break
170210 end
171211 end
172212end
@@ -204,26 +244,59 @@ end
204244--- @param client WebSocketClient The client object
205245--- @param code number | nil Close code (default : 1000)
206246--- @param reason string | nil Close reason
207- function M .close_client (client , code , reason )
247+ --- @param on_done function | nil Optional callback run after the Close frame has been
248+ --- written (or once the connection is already gone). Protocol-violation paths use
249+ --- it to defer error/teardown until the status code has actually been flushed.
250+ function M .close_client (client , code , reason , on_done )
208251 if client .state == " closed" or client .state == " closing" then
252+ if on_done then
253+ vim .schedule (on_done )
254+ end
209255 return
210256 end
211257
212258 code = code or 1000
213259 reason = reason or " "
214260
261+ -- Defensive guard for callers that don't gate on client.state: if the
262+ -- underlying handle is already closing (e.g. a TCP error path closed it),
263+ -- writing a Close frame would target a dead handle and never reach the peer.
264+ -- Just mark the client closed in that case. Mirrors the is_closing() check
265+ -- in tcp.lua's _remove_client.
266+ if client .tcp_handle :is_closing () then
267+ client .state = " closed"
268+ if on_done then
269+ vim .schedule (on_done )
270+ end
271+ return
272+ end
273+
215274 if client .handshake_complete then
216275 local close_frame = frame .create_close_frame (code , reason )
217276 client .tcp_handle :write (close_frame , function ()
218277 client .state = " closed"
219- client .tcp_handle :close ()
278+ if not client .tcp_handle :is_closing () then
279+ client .tcp_handle :close ()
280+ end
281+ -- Run any teardown only after the Close frame has been flushed, so a
282+ -- caller's synchronous handle close cannot cancel the pending write. libuv
283+ -- always invokes this callback (on success or error), so on_done is never
284+ -- dropped.
285+ if on_done then
286+ on_done ()
287+ end
220288 end )
289+ -- Mark as "closing" while the Close frame write/teardown is in flight. The
290+ -- write callback transitions to "closed". Do not clobber the "closed" state
291+ -- set synchronously on the not-handshake-complete branch below.
292+ client .state = " closing"
221293 else
222294 client .state = " closed"
223295 client .tcp_handle :close ()
296+ if on_done then
297+ vim .schedule (on_done )
298+ end
224299 end
225-
226- client .state = " closing"
227300end
228301
229302--- Check if a client connection is alive
0 commit comments