@@ -152,6 +152,11 @@ class Gateway
152152 # - 4014: Use of disabled privileged intents.
153153 FATAL_CLOSE_CODES = [ 4003 , 4004 , 4011 , 4014 ] . freeze
154154
155+ # The timeout in seconds for responses from Discord when expecting one.
156+ # This is used when expecting a hello message after the initial connection
157+ # and when expecting a heartbeat ack after sending a heartbeat (if {#check_heartbeat_acks} is true).
158+ RESPONSE_TIMEOUT = 2
159+
155160 # Heartbeat ACKs are Discord's way of verifying on the client side whether the connection is still alive. If this is
156161 # set to true (default value) the gateway client will use that functionality to detect zombie connections and
157162 # reconnect in such a case; however it may lead to instability if there's some problem with the ACKs. If this occurs
@@ -267,6 +272,9 @@ def inject_error(e)
267272 # @see #send_heartbeat
268273 def heartbeat
269274 if check_heartbeat_acks
275+ # This check is still needed even though we have socket_timeout
276+ # because the Ruby version may be too old to support setting timeout on a socket
277+ # and because RESPONSE_TIMEOUT may be larger than the heartbeat interval.
270278 unless @last_heartbeat_acked
271279 # We're in a bad situation - apparently the last heartbeat wasn't ACK'd, which means the connection is likely
272280 # a zombie. Reconnect
@@ -279,6 +287,7 @@ def heartbeat
279287 end
280288
281289 @last_heartbeat_acked = false
290+ socket_timeout ( RESPONSE_TIMEOUT )
282291 end
283292
284293 send_heartbeat ( @session ? @session . sequence : 0 )
@@ -453,10 +462,22 @@ def send_raw(data, type = :text)
453462
454463 private
455464
465+ def socket_timeout ( duration )
466+ if @socket . respond_to? ( :timeout= )
467+ @socket . timeout = duration
468+ LOGGER . debug ( "Set socket timeout to #{ duration } " )
469+ else # Ruby version < 3.4
470+ LOGGER . debug ( 'Cannot set socket timeout because Ruby is too old' )
471+ end
472+ end
473+
456474 def setup_heartbeats ( interval )
457475 # Make sure to reset ACK handling, so we don't keep reconnecting
458476 @last_heartbeat_acked = true
459477
478+ # Socket timeout needs to be larger than heartbeat interval
479+ socket_timeout ( interval + 1 )
480+
460481 # We don't want to have redundant heartbeat threads, so if one already exists, don't start a new one
461482 return if @heartbeat_thread
462483
@@ -590,6 +611,9 @@ def connect
590611 @socket = obtain_socket ( gateway_uri )
591612 LOGGER . debug ( 'Obtained socket' )
592613
614+ # A hello message should be received quickly after it is connected
615+ socket_timeout ( RESPONSE_TIMEOUT )
616+
593617 # Initialise some properties
594618 @handshake = ::WebSocket ::Handshake ::Client . new ( url : url ) # Represents the handshake between us and the server
595619 @handshaked = false # Whether the handshake has finished yet
@@ -625,6 +649,10 @@ def websocket_loop
625649 @pipe_broken = true
626650 handle_internal_close ( 'Socket EOF in websocket_loop' )
627651 next
652+ rescue IO ::TimeoutError
653+ @pipe_broken = true
654+ handle_internal_close ( 'Socket did not receive a response in time' )
655+ next
628656 end
629657
630658 # Check if we actually got data
@@ -803,7 +831,10 @@ def handle_hello(packet)
803831 # Op 11
804832 def handle_heartbeat_ack ( packet )
805833 LOGGER . debug ( "Received heartbeat ack for packet: #{ packet . inspect } " )
806- @last_heartbeat_acked = true if @check_heartbeat_acks
834+
835+ return unless @check_heartbeat_acks
836+ @last_heartbeat_acked = true
837+ socket_timeout ( @heartbeat_interval + 1 )
807838 end
808839
809840 # Called when the websocket has been disconnected in some way - say due to a pipe error while sending
0 commit comments