Skip to content

Commit d58e0c0

Browse files
Refactor of SocketRelay and polish/comments (#10)
* Improve doc strings of user-facing managers + Refactor some internals * Refactor SocketRelay * Some fixes and require some arguments to be keyword * Fixed bug where run loop was not entered * Update README and formatting * Add get_script_config helper function * Fix wrong uses of spawn_id * Formatting * Fix base class docstrings * Improve comment on close_after_match * Call ensure_server_started in start_match and adjust some tests * Revert python version brainfart --------- Co-authored-by: Eric Veilleux <[email protected]>
1 parent 92e2aa8 commit d58e0c0

21 files changed

+628
-451
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ cython_debug/
161161
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
162162
# and can be added to the global gitignore or merged into this file. For a more nuclear
163163
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
164-
#.idea/
164+
.idea/
165165

166166
### Python Patch ###
167167
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration

README.md

+11-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# python-interface
1+
# RLBot Python Interface
22

33
A high performance Python interface for communicating with RLBot v5.
44

@@ -19,11 +19,18 @@ The following is how to setup a development environment for this project, NOT ho
1919
- Install the package
2020
- `pip install --editable .`
2121
- This will install the package in editable mode,
22-
meaning you can make changes to the code and they
22+
meaning you can make changes to the code, and they
2323
will be reflected in the installed package without
2424
having to run the command again
25-
26-
This project is formatted using Black.
25+
- If you are making changes involving the flatbuffer schema and
26+
[rlbot_flatbuffers_py](https://github.com/VirxEC/rlbot_flatbuffers_py),
27+
also install your local copy of that package in editable mode:
28+
- `pip uninstall rlbot_flatbuffers`
29+
- `pip install --editable <path/to/rlbot_flatbuffers>`
30+
31+
This project is formatted using [Black](https://github.com/psf/black).
32+
- Install: `pip install black`.
33+
- Use: `black .`
2734

2835
## Testing
2936

rlbot/interface.py

+135-102
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import logging
2+
import time
23
from collections.abc import Callable
34
from enum import IntEnum
45
from pathlib import Path
5-
from socket import IPPROTO_TCP, TCP_NODELAY, socket, timeout
6+
from socket import IPPROTO_TCP, TCP_NODELAY, socket
67
from threading import Thread
7-
from time import sleep
88
from typing import Optional
99

1010
from rlbot import flat
@@ -16,7 +16,8 @@
1616

1717
class 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

6465
class 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

Comments
 (0)