11import logging
2+ import time
23from collections .abc import Callable
34from enum import IntEnum
45from pathlib import Path
5- from socket import IPPROTO_TCP , TCP_NODELAY , socket , timeout
6+ from socket import IPPROTO_TCP , TCP_NODELAY , socket
67from threading import Thread
7- from time import sleep
88from typing import Optional
99
1010from rlbot import flat
1616
1717class SocketDataType (IntEnum ):
1818 """
19- https://wiki.rlbot.org/framework/sockets-specification/#data-types
19+ See https://github.com/RLBot/core/blob/master/RLBotCS/Types/DataType.cs
20+ and https://wiki.rlbot.org/framework/sockets-specification/#data-types
2021 """
2122
2223 NONE = 0
@@ -54,16 +55,25 @@ def __init__(self, type: int, data: bytes):
5455 self .data = data
5556
5657
57- def read_from_socket (s : socket ) -> SocketMessage :
58+ def read_message_from_socket (s : socket ) -> SocketMessage :
5859 type_int = int_from_bytes (s .recv (2 ))
5960 size = int_from_bytes (s .recv (2 ))
6061 data = s .recv (size )
6162 return SocketMessage (type_int , data )
6263
6364
6465class SocketRelay :
66+ """
67+ The SocketRelay provides an abstraction over the direct communication with
68+ the RLBotServer making it easy to send the various types of messages.
69+
70+ Common use patterns are covered by `bot.py`, `script.py`, `hivemind.py`, and `match.py`
71+ from `rlbot.managers`.
72+ """
73+
6574 is_connected = False
66- _should_continue = True
75+ _running = False
76+ """Indicates whether a messages are being handled by the `run` loop (potentially in a background thread)"""
6777
6878 on_connect_handlers : list [Callable [[], None ]] = []
6979 packet_handlers : list [Callable [[flat .GamePacket ], None ]] = []
@@ -93,10 +103,12 @@ def __del__(self):
93103 self .socket .close ()
94104
95105 def send_bytes (self , data : bytes , data_type : SocketDataType ):
106+ assert self .is_connected , "Connection has not been established"
107+
96108 size = len (data )
97109 if size > MAX_SIZE_2_BYTES :
98110 self .logger .error (
99- "Couldn't send a %s message because it was too big!" , data_type
111+ "Couldn't send %s message because it was too big!" , data_type . name
100112 )
101113 return
102114
@@ -129,11 +141,9 @@ def stop_match(self, shutdown_server: bool = False):
129141 flatbuffer = flat .StopCommand (shutdown_server ).pack ()
130142 self .send_bytes (flatbuffer , SocketDataType .STOP_COMMAND )
131143
132- def start_match (
133- self ,
134- match_config : Path | flat .MatchSettings ,
135- rlbot_server_port : int = RLBOT_SERVER_PORT ,
136- ):
144+ def start_match (self , match_config : Path | flat .MatchSettings ):
145+ self .logger .info ("Python interface is attempting to start match..." )
146+
137147 match match_config :
138148 case Path () as path :
139149 string_path = str (path .absolute ().resolve ())
@@ -142,55 +152,69 @@ def start_match(
142152 case flat .MatchSettings () as settings :
143153 flatbuffer = settings .pack ()
144154 flat_type = SocketDataType .MATCH_SETTINGS
155+ case _:
156+ raise ValueError (
157+ "Expected MatchSettings or path to match settings toml file"
158+ )
145159
146- def connect_handler ():
147- self .send_bytes (flatbuffer , flat_type )
148-
149- self .run_after_connect (connect_handler , rlbot_server_port )
150-
151- def run_after_connect (
152- self ,
153- handler : Callable [[], None ],
154- rlbot_server_port : int = RLBOT_SERVER_PORT ,
155- ):
156- if self .is_connected :
157- handler ()
158- else :
159- self .on_connect_handlers .append (handler )
160- try :
161- self .connect_and_run (False , False , False , True , rlbot_server_port )
162- except timeout as e :
163- raise TimeoutError (
164- "Took too long to connect to the RLBot executable!"
165- ) from e
160+ self .send_bytes (flatbuffer , flat_type )
166161
167162 def connect (
168163 self ,
164+ * ,
169165 wants_match_communications : bool ,
170166 wants_ball_predictions : bool ,
171167 close_after_match : bool = True ,
172168 rlbot_server_port : int = RLBOT_SERVER_PORT ,
173169 ):
174170 """
175- Connects to the socket and sends the connection settings.
171+ Connects to the RLBot server specifying the given settings.
172+
173+ - wants_match_communications: Whether match communication messages should be sent to this process.
174+ - wants_ball_predictions: Whether ball prediction messages should be sent to this process.
175+ - close_after_match: Whether RLBot should close this connection between matches, specifically upon
176+ `StartMatch` and `StopMatch` messages, since RLBot does not actually detect the ending of matches.
176177
177- NOTE: Bad things happen if the buffer is allowed to fill up. Ensure
178- `handle_incoming_messages` is called frequently enough to prevent this.
178+ NOTE: Bad things happen if the message buffer fills up. Ensure `handle_incoming_messages` is called
179+ frequently to prevent this. See `run` for handling messages continuously .
179180 """
181+ assert not self .is_connected , "Connection has already been established"
182+
180183 self .socket .settimeout (self .connection_timeout )
181- for _ in range (int (self .connection_timeout * 10 )):
182- try :
183- self .socket .connect (("127.0.0.1" , rlbot_server_port ))
184- break
185- except ConnectionRefusedError :
186- sleep (0.1 )
187- except ConnectionAbortedError :
188- sleep (0.1 )
189-
190- self .socket .settimeout (None )
191- self .is_connected = True
184+ try :
185+ begin_time = time .time ()
186+ next_warning = 10
187+ while time .time () < begin_time + self .connection_timeout :
188+ try :
189+ self .socket .connect (("127.0.0.1" , rlbot_server_port ))
190+ self .is_connected = True
191+ break
192+ except ConnectionRefusedError :
193+ time .sleep (0.1 )
194+ except ConnectionAbortedError :
195+ time .sleep (0.1 )
196+ if time .time () > begin_time + next_warning :
197+ next_warning *= 2
198+ self .logger .warning (
199+ "Connection is being refused/aborted. Trying again ..."
200+ )
201+ if not self .is_connected :
202+ raise ConnectionRefusedError (
203+ "Connection was refused/aborted repeatedly! "
204+ "Ensure that Rocket League and the RLBotServer is running. "
205+ "Try calling `ensure_server_started()` before connecting."
206+ )
207+ except TimeoutError as e :
208+ raise TimeoutError (
209+ "Took too long to connect to the RLBot! "
210+ "Ensure that Rocket League and the RLBotServer is running."
211+ "Try calling `ensure_server_started()` before connecting."
212+ ) from e
213+ finally :
214+ self .socket .settimeout (None )
215+
192216 self .logger .info (
193- "Socket manager connected to port %s from port %s!" ,
217+ "SocketRelay connected to port %s from port %s!" ,
194218 rlbot_server_port ,
195219 self .socket .getsockname ()[1 ],
196220 )
@@ -199,76 +223,75 @@ def connect(
199223 handler ()
200224
201225 flatbuffer = flat .ConnectionSettings (
202- self .agent_id ,
203- wants_ball_predictions ,
204- wants_match_communications ,
205- close_after_match ,
226+ agent_id = self .agent_id ,
227+ wants_ball_predictions = wants_ball_predictions ,
228+ wants_comms = wants_match_communications ,
229+ close_after_match = close_after_match ,
206230 ).pack ()
207231 self .send_bytes (flatbuffer , SocketDataType .CONNECTION_SETTINGS )
208232
209- def connect_and_run (
210- self ,
211- wants_match_communications : bool ,
212- wants_ball_predictions : bool ,
213- close_after_match : bool = True ,
214- only_wait_for_ready : bool = False ,
215- rlbot_server_port : int = RLBOT_SERVER_PORT ,
216- ):
233+ def run (self , * , background_thread : bool = False ):
217234 """
218- Connects to the socket and begins a loop that reads messages and calls any handlers
219- that have been registered. Connect and run are combined into a single method because
220- currently bad things happen if the buffer is allowed to fill up.
235+ Handle incoming messages until disconnected.
236+ If `background_thread` is `True`, a background thread will be started for this.
221237 """
222- self .connect (
223- wants_match_communications ,
224- wants_ball_predictions ,
225- close_after_match ,
226- rlbot_server_port ,
227- )
228-
229- incoming_message = read_from_socket (self .socket )
230- self .handle_incoming_message (incoming_message )
231-
232- if only_wait_for_ready :
233- Thread (target = self .handle_incoming_messages ).start ()
238+ assert self .is_connected , "Connection has not been established"
239+ assert not self ._running , "Message handling is already running"
240+ if background_thread :
241+ Thread (target = self .run ).start ()
234242 else :
235- self .handle_incoming_messages ()
243+ self ._running = True
244+ while self ._running and self .is_connected :
245+ self ._running = self .handle_incoming_messages (blocking = True )
246+ self ._running = False
236247
237- def handle_incoming_messages (self , set_nonblocking_after_recv : bool = False ):
248+ def handle_incoming_messages (self , blocking = False ) -> bool :
249+ """
250+ Empties queue of incoming messages (should be called regularly, see `run`).
251+ Optionally blocking, ensuring that at least one message will be handled.
252+ Returns true message handling should continue running, and
253+ false if RLBotServer has asked us to shut down or an error happened.
254+ """
255+ assert self .is_connected , "Connection has not been established"
238256 try :
239- while self ._should_continue :
240- incoming_message = read_from_socket (self .socket )
241-
242- if set_nonblocking_after_recv :
243- self .socket .setblocking (False )
244-
245- try :
246- self .handle_incoming_message (incoming_message )
247- except flat .InvalidFlatbuffer as e :
248- self .logger .error (
249- "Error while unpacking message of type %s (%s bytes): %s" ,
250- incoming_message .type .name ,
251- len (incoming_message .data ),
252- e ,
253- )
254- except Exception as e :
255- self .logger .warning (
256- "Unexpected error while handling message of type %s: %s" ,
257- incoming_message .type .name ,
258- e ,
259- )
257+ self .socket .setblocking (blocking )
258+ incoming_message = read_message_from_socket (self .socket )
259+ try :
260+ return self .handle_incoming_message (incoming_message )
261+ except flat .InvalidFlatbuffer as e :
262+ self .logger .error (
263+ "Error while unpacking message of type %s (%s bytes): %s" ,
264+ incoming_message .type .name ,
265+ len (incoming_message .data ),
266+ e ,
267+ )
268+ return False
269+ except Exception as e :
270+ self .logger .error (
271+ "Unexpected error while handling message of type %s: %s" ,
272+ incoming_message .type .name ,
273+ e ,
274+ )
275+ return False
260276 except BlockingIOError :
261- raise BlockingIOError
277+ # No incoming messages and blocking==False
278+ return True
262279 except :
263- self .logger .error ("Socket manager disconnected unexpectedly!" )
280+ self .logger .error ("SocketRelay disconnected unexpectedly!" )
281+ return False
264282
265283 def handle_incoming_message (self , incoming_message : SocketMessage ):
284+ """
285+ Handles a messages by passing it to the relevant handlers.
286+ Returns True if the message was NOT a shutdown request (i.e. NONE).
287+ """
288+
266289 for raw_handler in self .raw_handlers :
267290 raw_handler (incoming_message )
268291
269292 match incoming_message .type :
270293 case SocketDataType .NONE :
271- self . _should_continue = False
294+ return False
272295 case SocketDataType .GAME_PACKET :
273296 if len (self .packet_handlers ) > 0 :
274297 packet = flat .GamePacket .unpack (incoming_message .data )
@@ -302,13 +325,23 @@ def handle_incoming_message(self, incoming_message: SocketMessage):
302325 for handler in self .controllable_team_info_handlers :
303326 handler (player_mappings )
304327
328+ return True
329+
305330 def disconnect (self ):
306331 if not self .is_connected :
307332 self .logger .warning ("Asked to disconnect but was already disconnected." )
308333 return
309334
310335 self .send_bytes (bytes ([1 ]), SocketDataType .NONE )
311- while self ._should_continue :
312- sleep (0.1 )
313-
336+ timeout = 5.0
337+ while self ._running and timeout > 0 :
338+ time .sleep (0.1 )
339+ timeout -= 0.1
340+ if timeout <= 0 :
341+ self .logger .critical ("RLBot is not responding to our disconnect request!?" )
342+ self ._running = False
343+
344+ assert (
345+ not self ._running
346+ ), "Disconnect request or timeout should have set self._running to False"
314347 self .is_connected = False
0 commit comments