sr-robot3 2024.0.1__py3-none-any.whl → 2025.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sr/robot3/robot.py CHANGED
@@ -3,8 +3,10 @@ from __future__ import annotations
3
3
 
4
4
  import itertools
5
5
  import logging
6
+ import os
6
7
  import time
7
8
  from pathlib import Path
9
+ from socket import socket
8
10
  from types import MappingProxyType
9
11
  from typing import Mapping, Optional
10
12
 
@@ -20,7 +22,8 @@ from .motor_board import MotorBoard
20
22
  from .power_board import Note, PowerBoard
21
23
  from .raw_serial import RawSerial
22
24
  from .servo_board import ServoBoard
23
- from .utils import ensure_atexit_on_term, obtain_lock, singular
25
+ from .simulator.time_server import TimeServer
26
+ from .utils import IN_SIMULATOR, ensure_atexit_on_term, obtain_lock, singular
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
 
@@ -40,10 +43,12 @@ class Robot:
40
43
  :param manual_boards: A dictionary of board types to a list of serial port paths
41
44
  to allow for connecting to boards that are not automatically detected, defaults to None
42
45
  :param raw_ports: A list of serial number, baudrate tuples to try connecting to.
46
+ :param no_powerboard: If True, initialize the robot without a powerboard, defaults to False
43
47
  """
44
48
  __slots__ = (
45
49
  '_lock', '_metadata', '_power_board', '_motor_boards', '_servo_boards',
46
50
  '_arduinos', '_cameras', '_mqtt', '_astoria', '_kch', '_raw_ports',
51
+ '_time_server', '_no_powerboard',
47
52
  )
48
53
 
49
54
  def __init__(
@@ -55,16 +60,30 @@ class Robot:
55
60
  ignored_arduinos: Optional[list[str]] = None,
56
61
  manual_boards: Optional[dict[str, list[str]]] = None,
57
62
  raw_ports: Optional[list[tuple[str, int]]] = None,
63
+ no_powerboard: bool = False,
58
64
  ) -> None:
59
- self._lock = obtain_lock()
65
+ self._lock: TimeServer | socket | None
66
+ if IN_SIMULATOR:
67
+ self._lock = TimeServer.initialise()
68
+ if self._lock is None:
69
+ raise OSError(
70
+ 'Unable to obtain lock, Is another robot instance already running?'
71
+ )
72
+ else:
73
+ self._lock = obtain_lock()
60
74
  self._metadata: Optional[Metadata] = None
75
+ self._no_powerboard = no_powerboard
61
76
 
62
77
  setup_logging(debug, trace_logging)
63
78
  ensure_atexit_on_term()
64
79
 
65
80
  logger.info(f"sr.robot3 version {__version__}")
66
81
 
67
- self._mqtt, self._astoria = init_astoria_mqtt()
82
+ if IN_SIMULATOR:
83
+ self._mqtt = None
84
+ self._astoria = None
85
+ else:
86
+ self._mqtt, self._astoria = init_astoria_mqtt()
68
87
 
69
88
  if manual_boards:
70
89
  self._init_power_board(manual_boards.get(PowerBoard.get_board_type(), []))
@@ -88,7 +107,10 @@ class Robot:
88
107
  defaults to None
89
108
  :raises RuntimeError: If exactly one PowerBoard is not found
90
109
  """
91
- power_boards = PowerBoard._get_supported_boards(manual_boards)
110
+ if self._no_powerboard:
111
+ return
112
+
113
+ power_boards = PowerBoard._get_supported_boards(manual_boards, sleep_fn=self.sleep)
92
114
  self._power_board = singular(power_boards)
93
115
 
94
116
  # Enable all the outputs, so that we can find other boards.
@@ -125,7 +147,10 @@ class Robot:
125
147
  self._servo_boards = ServoBoard._get_supported_boards(manual_servoboards)
126
148
  self._arduinos = Arduino._get_supported_boards(manual_arduinos, ignored_arduinos)
127
149
  if raw_ports:
128
- self._raw_ports = RawSerial._get_supported_boards(raw_ports)
150
+ if not IN_SIMULATOR:
151
+ self._raw_ports = RawSerial._get_supported_boards(raw_ports)
152
+ else:
153
+ logger.warning("Raw ports are not available in the simulator.")
129
154
 
130
155
  def _init_camera(self) -> None:
131
156
  """
@@ -134,9 +159,15 @@ class Robot:
134
159
  These cameras are used for AprilTag detection and provide location data of
135
160
  markers in its field of view.
136
161
  """
162
+ if IN_SIMULATOR:
163
+ publish_fn = None
164
+ else:
165
+ assert self._mqtt is not None
166
+ publish_fn = self._mqtt.wrapped_publish
167
+
137
168
  self._cameras = MappingProxyType(_setup_cameras(
138
169
  game_specific.MARKER_SIZES,
139
- self._mqtt.wrapped_publish,
170
+ publish_fn,
140
171
  ))
141
172
 
142
173
  def _log_connected_boards(self) -> None:
@@ -145,8 +176,10 @@ class Robot:
145
176
 
146
177
  Firmware versions are also logged at debug level.
147
178
  """
179
+ # we only have one power board so make it iterable
180
+ power_board = [] if self._no_powerboard else [self.power_board]
148
181
  boards = itertools.chain(
149
- [self.power_board], # we only have one power board so make it iterable
182
+ power_board,
150
183
  self.motor_boards.values(),
151
184
  self.servo_boards.values(),
152
185
  self.arduinos.values(),
@@ -178,6 +211,9 @@ class Robot:
178
211
 
179
212
  :return: The power board object
180
213
  """
214
+ if self._no_powerboard:
215
+ raise RuntimeError("No power board was initialized")
216
+
181
217
  return self._power_board
182
218
 
183
219
  @property
@@ -279,27 +315,35 @@ class Robot:
279
315
  """
280
316
  Sleep for a number of seconds.
281
317
 
282
- This is a convenience method that can be used instead of time.sleep().
283
- This exists for compatibility with the simulator API only.
318
+ This is a convenience method that should be used instead of time.sleep()
319
+ to make your code compatible with the simulator.
284
320
 
285
321
  :param secs: The number of seconds to sleep for
286
322
  """
287
- time.sleep(secs)
323
+ if IN_SIMULATOR:
324
+ assert isinstance(self._lock, TimeServer)
325
+ self._lock.sleep(secs)
326
+ else:
327
+ time.sleep(secs)
288
328
 
289
329
  @log_to_debug
290
330
  def time(self) -> float:
291
331
  """
292
332
  Get the number of seconds since the Unix Epoch.
293
333
 
294
- This is a convenience method that can be used instead of time.time().
295
- This exists for compatibility with the simulator API only.
334
+ This is a convenience method that should be used instead of time.time()
335
+ to make your code compatible with the simulator.
296
336
 
297
337
  NOTE: The robot's clock resets each time the robot is restarted, so this
298
338
  will not be the correct actual time but can be used to measure elapsed time.
299
339
 
300
340
  :returns: the number of seconds since the Unix Epoch.
301
341
  """
302
- return time.time()
342
+ if IN_SIMULATOR:
343
+ assert isinstance(self._lock, TimeServer)
344
+ return self._lock.get_time()
345
+ else:
346
+ return time.time()
303
347
 
304
348
  @property
305
349
  @log_to_debug
@@ -350,7 +394,11 @@ class Robot:
350
394
 
351
395
  :returns: path to the mountpoint of the USB code drive.
352
396
  """
353
- return self._astoria.fetch_mount_path()
397
+ if IN_SIMULATOR:
398
+ return Path(os.environ['SBOT_USBKEY_PATH'])
399
+ else:
400
+ assert self._astoria is not None
401
+ return self._astoria.fetch_mount_path()
354
402
 
355
403
  @property
356
404
  def is_simulated(self) -> bool:
@@ -359,7 +407,7 @@ class Robot:
359
407
 
360
408
  :returns: True if the robot is simulated. False otherwise.
361
409
  """
362
- return False
410
+ return IN_SIMULATOR
363
411
 
364
412
  @log_to_debug
365
413
  def wait_start(self) -> None:
@@ -371,25 +419,47 @@ class Robot:
371
419
  Once the start button is pressed, the metadata will be loaded and the timeout
372
420
  will start if in competition mode.
373
421
  """
422
+ def null_button_pressed() -> bool:
423
+ return False
424
+
425
+ if self._no_powerboard:
426
+ # null out the start button function
427
+ start_button_pressed = null_button_pressed
428
+ else:
429
+ start_button_pressed = self.power_board._start_button
430
+
431
+ if IN_SIMULATOR:
432
+ remote_start_pressed = null_button_pressed
433
+ else:
434
+ assert self._astoria is not None
435
+ remote_start_pressed = self._astoria.get_start_button_pressed
436
+
374
437
  # ignore previous button presses
375
- _ = self.power_board._start_button()
376
- _ = self._astoria.get_start_button_pressed()
438
+ _ = start_button_pressed()
439
+ _ = remote_start_pressed()
377
440
  logger.info('Waiting for start button.')
378
441
 
379
- self.power_board.piezo.buzz(Note.A6, 0.1)
380
- self.power_board._run_led.flash()
442
+ if not self._no_powerboard:
443
+ self.power_board.piezo.buzz(Note.A6, 0.1)
444
+ self.power_board._run_led.flash()
381
445
  self.kch._flash_start()
382
446
 
383
- while not (
384
- self.power_board._start_button() or self._astoria.get_start_button_pressed()
385
- ):
386
- time.sleep(0.1)
447
+ while not start_button_pressed() or remote_start_pressed():
448
+ self.sleep(0.1)
387
449
  logger.info("Start signal received; continuing.")
388
- self.power_board._run_led.on()
450
+ if not self._no_powerboard:
451
+ self.power_board._run_led.on()
389
452
  self.kch._start = False
390
453
 
391
- # Load the latest metadata that we have received over MQTT
392
- self._metadata = self._astoria.fetch_current_metadata()
454
+ if IN_SIMULATOR:
455
+ self._metadata = Metadata.parse_file(
456
+ Path(os.environ['SBOT_METADATA_PATH']) / 'astoria.json'
457
+ )
458
+ else:
459
+ assert self._astoria is not None
460
+ # Load the latest metadata that we have received over MQTT
461
+ self._metadata = self._astoria.fetch_current_metadata()
393
462
 
394
- if self._metadata.game_timeout is not None:
463
+ # Simulator timeout is handled by the simulator supervisor
464
+ if self._metadata.game_timeout and not IN_SIMULATOR:
395
465
  timeout.kill_after_delay(self._metadata.game_timeout)
@@ -17,7 +17,7 @@ import serial
17
17
 
18
18
  from .exceptions import BoardDisconnectionError
19
19
  from .logging import TRACE
20
- from .utils import BoardIdentity
20
+ from .utils import IN_SIMULATOR, BoardIdentity
21
21
 
22
22
  logger = logging.getLogger(__name__)
23
23
 
@@ -29,6 +29,12 @@ Param = ParamSpec("Param")
29
29
  RetType = TypeVar("RetType")
30
30
 
31
31
  E = TypeVar("E", bound=BaseException)
32
+ BASE_TIMEOUT: float | None
33
+
34
+ if IN_SIMULATOR:
35
+ BASE_TIMEOUT = None # Disable timeouts while in the simulator to allow for pausing
36
+ else:
37
+ BASE_TIMEOUT = 0.5
32
38
 
33
39
 
34
40
  def retry(
@@ -80,7 +86,7 @@ class SerialWrapper:
80
86
  self,
81
87
  port: str,
82
88
  baud: int,
83
- timeout: float = 0.5,
89
+ timeout: float | None = BASE_TIMEOUT,
84
90
  identity: BoardIdentity = BoardIdentity(),
85
91
  delay_after_connect: float = 0,
86
92
  ):
@@ -96,6 +102,7 @@ class SerialWrapper:
96
102
  port,
97
103
  baudrate=baud,
98
104
  timeout=timeout,
105
+ write_timeout=timeout,
99
106
  do_not_open=True,
100
107
  )
101
108
 
@@ -206,9 +213,10 @@ class SerialWrapper:
206
213
  """
207
214
  try:
208
215
  self.serial.open()
209
- # Wait for the board to be ready to receive data
210
- # Certain boards will reset when the serial port is opened
211
- time.sleep(self.delay_after_connect)
216
+ if not IN_SIMULATOR:
217
+ # Wait for the board to be ready to receive data
218
+ # Certain boards will reset when the serial port is opened
219
+ time.sleep(self.delay_after_connect)
212
220
  self.serial.reset_input_buffer()
213
221
  except serial.SerialException:
214
222
  logger.error(
sr/robot3/servo_board.py CHANGED
@@ -12,14 +12,15 @@ from .exceptions import IncorrectBoardError
12
12
  from .logging import log_to_debug
13
13
  from .serial_wrapper import SerialWrapper
14
14
  from .utils import (
15
- Board, BoardIdentity, float_bounds_check,
16
- get_USB_identity, map_to_float, map_to_int,
15
+ IN_SIMULATOR, Board, BoardIdentity, float_bounds_check,
16
+ get_simulator_boards, get_USB_identity, map_to_float, map_to_int,
17
17
  )
18
18
 
19
19
  DUTY_MIN = 500
20
20
  DUTY_MAX = 4000
21
21
  START_DUTY_MIN = 1000
22
22
  START_DUTY_MAX = 2000
23
+ NUM_SERVOS = 12
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
  BAUDRATE = 115200 # Since the servo board is a USB device, this is ignored
@@ -77,7 +78,7 @@ class ServoBoard(Board):
77
78
  self._serial = SerialWrapper(serial_port, BAUDRATE, identity=initial_identity)
78
79
 
79
80
  self._servos = tuple(
80
- Servo(self._serial, index) for index in range(12)
81
+ Servo(self._serial, index) for index in range(NUM_SERVOS)
81
82
  )
82
83
 
83
84
  self._identity = self.identify()
@@ -109,17 +110,46 @@ class ServoBoard(Board):
109
110
  f"expected {err.expected_type!r}. Ignoring this device")
110
111
  return None
111
112
  except Exception:
112
- if initial_identity is not None and initial_identity.board_type == 'manual':
113
- logger.warning(
114
- f"Manually specified servo board at port {serial_port!r}, "
115
- "could not be identified. Ignoring this device")
116
- else:
117
- logger.warning(
118
- f"Found servo board-like serial port at {serial_port!r}, "
119
- "but it could not be identified. Ignoring this device")
113
+ if initial_identity is not None:
114
+ if initial_identity.board_type == 'manual':
115
+ logger.warning(
116
+ f"Manually specified servo board at port {serial_port!r}, "
117
+ "could not be identified. Ignoring this device")
118
+ elif initial_identity.manufacturer == 'sbot_simulator':
119
+ logger.warning(
120
+ f"Simulator specified servo board at port {serial_port!r}, "
121
+ "could not be identified. Ignoring this device")
122
+ return None
123
+
124
+ logger.warning(
125
+ f"Found servo board-like serial port at {serial_port!r}, "
126
+ "but it could not be identified. Ignoring this device")
120
127
  return None
121
128
  return board
122
129
 
130
+ @classmethod
131
+ def _get_simulator_boards(cls) -> MappingProxyType[str, ServoBoard]:
132
+ """
133
+ Get the simulator boards.
134
+
135
+ :return: A mapping of board serial numbers to boards.
136
+ """
137
+ boards = {}
138
+ # The filter here is the name of the emulated board in the simulator
139
+ for board_info in get_simulator_boards('ServoBoard'):
140
+
141
+ # Create board identity from the info given
142
+ initial_identity = BoardIdentity(
143
+ manufacturer='sbot_simulator',
144
+ board_type=board_info.type_str,
145
+ asset_tag=board_info.serial_number,
146
+ )
147
+ if (board := cls._get_valid_board(board_info.url, initial_identity)) is None:
148
+ continue
149
+
150
+ boards[board._identity.asset_tag] = board
151
+ return MappingProxyType(boards)
152
+
123
153
  @classmethod
124
154
  def _get_supported_boards(
125
155
  cls, manual_boards: Optional[list[str]] = None,
@@ -133,6 +163,9 @@ class ServoBoard(Board):
133
163
  to connect to, defaults to None
134
164
  :return: A mapping of serial numbers to servo boards.
135
165
  """
166
+ if IN_SIMULATOR:
167
+ return cls._get_simulator_boards()
168
+
136
169
  boards = {}
137
170
  serial_ports = comports()
138
171
  for port in serial_ports:
File without changes
@@ -0,0 +1,75 @@
1
+ import struct
2
+
3
+ import cv2
4
+ import numpy as np
5
+ from april_vision import FrameSource
6
+ from numpy.typing import NDArray
7
+ from serial import serial_for_url
8
+
9
+ from ..utils import BoardInfo
10
+
11
+ HEADER_SIZE = 5 # 1 byte for the type, 4 bytes for the length
12
+ IMAGE_TAG_ID = 0
13
+
14
+
15
+ class WebotsRemoteCameraSource(FrameSource):
16
+ # Webots cameras include an alpha channel, this informs april_vision of how to handle it
17
+ COLOURSPACE = cv2.COLOR_BGRA2GRAY
18
+
19
+ def __init__(self, camera_info: BoardInfo) -> None:
20
+ self.calibration = (0.0, 0.0, 0.0, 0.0)
21
+ # Use pyserial to give a nicer interface for connecting to the camera socket
22
+ self._serial = serial_for_url(camera_info.url, baudrate=115200, timeout=None)
23
+
24
+ # Check the camera is connected
25
+ response = self._make_request("*IDN?")
26
+ if not response.split(b":")[1].lower().startswith(b"cam"):
27
+ raise RuntimeError(f"Camera not connected to a camera, returned: {response!r}")
28
+
29
+ # Get the calibration data for this camera
30
+ response = self._make_request("CAM:CALIBRATION?")
31
+
32
+ # The calibration data is returned as a string of floats separated by colons
33
+ new_calibration = tuple(map(float, response.split(b":")))
34
+ assert len(new_calibration) == 4, f"Invalid calibration data: {new_calibration}"
35
+ self.calibration = new_calibration
36
+
37
+ # Get the image size for this camera
38
+ response = self._make_request("CAM:RESOLUTION?")
39
+ self.image_size = tuple(map(int, response.split(b":")))
40
+ assert len(self.image_size) == 2, f"Invalid image dimensions: {self.image_size}"
41
+
42
+ def read(self, fresh: bool = True) -> NDArray:
43
+ """
44
+ The method for getting a new frame.
45
+
46
+ :param fresh: Whether to flush the device's buffer before capturing
47
+ the frame, unused.
48
+ """
49
+ self._serial.write(b"CAM:FRAME!\n")
50
+ # The image is encoded as a TLV (Type, Length, Value) packet
51
+ # so we need to read the header to get the type and length of the image
52
+ header = self._serial.read(HEADER_SIZE)
53
+ assert len(header) == HEADER_SIZE, f"Invalid header length: {len(header)}"
54
+ img_tag, img_len = struct.unpack('>BI', header)
55
+ assert img_tag == IMAGE_TAG_ID, f"Invalid image tag: {img_tag}"
56
+
57
+ # Get the image data now we know the length
58
+ img_data = self._serial.read(img_len)
59
+ assert len(img_data) == img_len, f"Invalid image data length: {len(img_data)}"
60
+
61
+ rgb_frame_raw: NDArray[np.uint8] = np.frombuffer(img_data, np.uint8)
62
+
63
+ # Height is first, then width, then channels
64
+ return rgb_frame_raw.reshape((self.image_size[1], self.image_size[0], 4))
65
+
66
+ def close(self) -> None:
67
+ """Close the underlying socket on exit."""
68
+ self._serial.close()
69
+
70
+ def _make_request(self, command: str) -> bytes:
71
+ self._serial.write(command.encode() + b"\n")
72
+ response = self._serial.readline()
73
+ if not response.endswith(b"\n") or response.startswith(b"NACK:"):
74
+ raise RuntimeError(f"Failed to communicate with camera, returned: {response!r}")
75
+ return response
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from datetime import datetime
5
+
6
+ from ..exceptions import BoardDisconnectionError, IncorrectBoardError
7
+ from ..serial_wrapper import SerialWrapper
8
+ from ..utils import BoardIdentity, get_simulator_boards
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ BAUDRATE = 115200
13
+
14
+
15
+ class TimeServer:
16
+ @staticmethod
17
+ def get_board_type() -> str:
18
+ """
19
+ Return the type of the board.
20
+
21
+ :return: The literal string 'TimeServer'.
22
+ """
23
+ return 'TimeServer'
24
+
25
+ def __init__(
26
+ self,
27
+ serial_port: str,
28
+ initial_identity: BoardIdentity | None = None,
29
+ ) -> None:
30
+ if initial_identity is None:
31
+ initial_identity = BoardIdentity()
32
+ self._serial = SerialWrapper(
33
+ serial_port,
34
+ BAUDRATE,
35
+ identity=initial_identity,
36
+ # Disable the timeout so sleep works properly
37
+ timeout=None,
38
+ )
39
+
40
+ self._identity = self.identify()
41
+ if self._identity.board_type != self.get_board_type():
42
+ raise IncorrectBoardError(self._identity.board_type, self.get_board_type())
43
+ self._serial.set_identity(self._identity)
44
+
45
+ @classmethod
46
+ def initialise(cls) -> 'TimeServer' | None:
47
+ # The filter here is the name of the emulated board in the simulator
48
+ boards = get_simulator_boards('TimeServer')
49
+
50
+ if not boards:
51
+ return None
52
+
53
+ board_info = boards[0]
54
+
55
+ # Create board identity from the info given
56
+ initial_identity = BoardIdentity(
57
+ manufacturer='sbot_simulator',
58
+ board_type=board_info.type_str,
59
+ asset_tag=board_info.serial_number,
60
+ )
61
+
62
+ try:
63
+ board = cls(board_info.url, initial_identity)
64
+ except BoardDisconnectionError:
65
+ logger.warning(
66
+ f"Simulator specified time server at port {board_info.url!r}, "
67
+ "could not be identified. Ignoring this device")
68
+ return None
69
+ except IncorrectBoardError as err:
70
+ logger.warning(
71
+ f"Board returned type {err.returned_type!r}, "
72
+ f"expected {err.expected_type!r}. Ignoring this device")
73
+ return None
74
+
75
+ return board
76
+
77
+ def identify(self) -> BoardIdentity:
78
+ """
79
+ Get the identity of the board.
80
+
81
+ :return: The identity of the board.
82
+ """
83
+ response = self._serial.query('*IDN?')
84
+ return BoardIdentity(*response.split(':'))
85
+
86
+ def get_time(self) -> float:
87
+ time_str = self._serial.query('TIME?')
88
+ return datetime.fromisoformat(time_str).timestamp()
89
+
90
+ def sleep(self, duration: float) -> None:
91
+ if duration < 0:
92
+ raise ValueError("sleep length must be non-negative")
93
+
94
+ self._serial.query(f'SLEEP:{int(duration * 1000)}')
sr/robot3/timeout.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Functions for killing the robot after a certain amount of time."""
2
+ import atexit
2
3
  import logging
3
4
  import os
4
5
  import signal
@@ -9,6 +10,9 @@ from typing import Optional
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
 
13
+ TIMEOUT_MESSAGE = "Timeout expired: Game Over!"
14
+ EXIT_ATTEMPTS = 0
15
+
12
16
 
13
17
  def timeout_handler(signal_type: int, stack_frame: Optional[FrameType]) -> None:
14
18
  """
@@ -17,13 +21,34 @@ def timeout_handler(signal_type: int, stack_frame: Optional[FrameType]) -> None:
17
21
  This function is called when the timeout expires and will stop the robot's main process.
18
22
  In order for this to work, any threads that are created must be daemons.
19
23
 
24
+ If the process doesn't terminate clearly (perhaps because the exception was caught),
25
+ exit less cleanly.
26
+
20
27
  NOTE: This function is not called on Windows.
21
28
 
22
29
  :param signal_type: The signal that triggered this handler
23
30
  :param stack_frame: The stack frame at the time of the signal
24
31
  """
25
- logger.info("Timeout expired: Game Over!")
26
- exit(0)
32
+ global EXIT_ATTEMPTS
33
+ EXIT_ATTEMPTS += 1
34
+
35
+ if sys.platform == "win32":
36
+ raise AssertionError("This function should not be called on Windows")
37
+
38
+ if EXIT_ATTEMPTS == 1:
39
+ # Allow 2 seconds for the process to exit cleanly before killing it
40
+ signal.alarm(2)
41
+ logger.info(TIMEOUT_MESSAGE)
42
+ # Exit cleanly
43
+ exit(0)
44
+ else:
45
+ # The process didn't exit cleanly, so first call the cleanup handlers
46
+ # and use an unhanded alarm to force python to exit with a core dump
47
+ signal.signal(signal.SIGALRM, signal.SIG_DFL)
48
+ signal.alarm(2) # Allow 2 seconds for cleanup
49
+
50
+ atexit._run_exitfuncs()
51
+ exit(0)
27
52
 
28
53
 
29
54
  def win_timeout_handler() -> None:
@@ -35,7 +60,7 @@ def win_timeout_handler() -> None:
35
60
 
36
61
  NOTE: This function is only called on Windows.
37
62
  """
38
- logger.info("Timeout expired: Game Over!")
63
+ logger.info(TIMEOUT_MESSAGE)
39
64
  os.kill(os.getpid(), signal.SIGTERM)
40
65
 
41
66
 
@@ -52,6 +77,7 @@ def kill_after_delay(timeout_seconds: int) -> None:
52
77
  # Windows doesn't have SIGALRM,
53
78
  # so we approximate its functionality using a delayed SIGTERM
54
79
  timer = Timer(timeout_seconds, win_timeout_handler)
80
+ timer.daemon = True
55
81
  timer.start()
56
82
  else:
57
83
  signal.signal(signal.SIGALRM, timeout_handler)