Skip to content

Commit c657aec

Browse files
committed
fixes
1 parent 5481ce1 commit c657aec

7 files changed

Lines changed: 130 additions & 32 deletions

File tree

src/software/gameplay_tests/field_test_runner.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ def _runner(
7474
while time_elapsed_s < test_timeout_s:
7575
processing_start_time = time.time()
7676

77-
# Check for new GC commands at this time step
78-
for delay, cmd, team in gc_cmd_with_delay:
77+
# Check for new GC commands at this time step. Iterate over a copy
78+
# since we remove from the list while iterating.
79+
for delay, cmd, team in list(gc_cmd_with_delay):
7980
# If delay matches time
8081
if delay <= time_elapsed_s:
8182
# send command

src/software/gameplay_tests/simulated_test_runner.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ def _pre_run_setup(self, setup: (lambda: None)):
7171
:param setup: Function that sets up the world state
7272
"""
7373
world_state_received_buffer = ThreadSafeBuffer(1, WorldStateReceivedTrigger)
74-
self.simulator_proto_unix_io.register_observer(
75-
WorldStateReceivedTrigger, world_state_received_buffer
74+
self._register_observer(
75+
self.simulator_proto_unix_io,
76+
WorldStateReceivedTrigger,
77+
world_state_received_buffer,
7678
)
7779

7880
while True:
@@ -104,8 +106,9 @@ def _runner(
104106
while time_elapsed_s < test_timeout_s:
105107
processing_start_time = time.time()
106108

107-
# Check for new GC commands at this time step
108-
for delay, cmd, team in gc_cmd_with_delay:
109+
# Check for new GC commands at this time step. Iterate over a copy
110+
# since we remove from the list while iterating.
111+
for delay, cmd, team in list(gc_cmd_with_delay):
109112
# If delay matches time
110113
if delay <= time_elapsed_s:
111114
# send command

src/software/gameplay_tests/tbots_test_runner.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,28 +56,60 @@ def __init__(
5656
buffer_size=1, protobuf_type=RobotStatus
5757
)
5858

59-
self.blue_full_system_proto_unix_io.register_observer(
60-
SSL_WrapperPacket, self.ssl_wrapper_buffer
59+
# Track every observer we register so we can clean them up later. This
60+
# matters when the runner is short-lived but the ProtoUnixIOs outlive it
61+
# (e.g. running tests repeatedly from the test selector widget).
62+
self._registered_observers = []
63+
64+
self._register_observer(
65+
self.blue_full_system_proto_unix_io,
66+
SSL_WrapperPacket,
67+
self.ssl_wrapper_buffer,
6168
)
62-
self.blue_full_system_proto_unix_io.register_observer(
63-
RobotStatus, self.robot_status_buffer
69+
self._register_observer(
70+
self.blue_full_system_proto_unix_io, RobotStatus, self.robot_status_buffer
6471
)
6572
if self.is_yellow_friendly:
66-
self.yellow_full_system_proto_unix_io.register_observer(
67-
World, self.world_buffer
73+
self._register_observer(
74+
self.yellow_full_system_proto_unix_io, World, self.world_buffer
6875
)
69-
self.yellow_full_system_proto_unix_io.register_observer(
70-
PrimitiveSet, self.primitive_set_buffer
76+
self._register_observer(
77+
self.yellow_full_system_proto_unix_io,
78+
PrimitiveSet,
79+
self.primitive_set_buffer,
7180
)
7281
# Only validate on the blue worlds
7382
else:
74-
self.blue_full_system_proto_unix_io.register_observer(
75-
World, self.world_buffer
83+
self._register_observer(
84+
self.blue_full_system_proto_unix_io, World, self.world_buffer
7685
)
77-
self.blue_full_system_proto_unix_io.register_observer(
78-
PrimitiveSet, self.primitive_set_buffer
86+
self._register_observer(
87+
self.blue_full_system_proto_unix_io,
88+
PrimitiveSet,
89+
self.primitive_set_buffer,
7990
)
8091

92+
def _register_observer(self, proto_unix_io, proto_class, buffer):
93+
"""Register an observer on a ProtoUnixIO and record it for cleanup.
94+
95+
:param proto_unix_io: the ProtoUnixIO to register on
96+
:param proto_class: the protobuf class to observe
97+
:param buffer: the buffer to push observed protos onto
98+
"""
99+
proto_unix_io.register_observer(proto_class, buffer)
100+
self._registered_observers.append((proto_unix_io, proto_class, buffer))
101+
102+
def unregister_observers(self):
103+
"""Unregister every observer this runner registered.
104+
105+
Call this when the runner is done but its ProtoUnixIOs live on, to avoid
106+
leaking stale buffers that keep receiving protos (e.g. when running tests
107+
repeatedly from the test selector widget in --test_mode).
108+
"""
109+
for proto_unix_io, proto_class, buffer in self._registered_observers:
110+
proto_unix_io.unregister_observer(proto_class, buffer)
111+
self._registered_observers = []
112+
81113
def send_gamecontroller_command(
82114
self,
83115
gc_command: proto.ssl_gc_state_pb2.Command,

src/software/thunderscope/common/test_bridge.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,21 @@ def _execute_test(self, item):
101101
else:
102102
test_func(gameplay_test_runner=runner)
103103

104+
# run_test() starts the test in a background thread (Thunderscope is
105+
# owned externally), so block here until it actually finishes before
106+
# reporting the result.
107+
runner.wait_for_test()
108+
if runner.last_exception is not None:
109+
raise runner.last_exception
110+
104111
if self._on_test_completed:
105112
self._on_test_completed(node_id, True, "Passed")
106113
except Exception as e:
107114
if self._on_test_completed:
108115
self._on_test_completed(node_id, False, str(e))
109116
finally:
117+
if self._current_runner is not None:
118+
self._current_runner.unregister_observers()
110119
self._running = False
111120
self._current_runner = None
112121

src/software/thunderscope/common/test_discovery.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,30 @@ def discover_test_targets(workspace_root: str = "") -> list[TestItem]:
3434
# If we are inside ``bazel run``/``bazel test`` we cannot recursively
3535
# invoke ``bazel query`` because it will block on the parent lock.
3636
inside_bazel = bool(os.environ.get("BUILD_WORKSPACE_DIRECTORY"))
37+
items = None
3738
if not inside_bazel:
3839
try:
39-
return _query_bazel_tests(workspace_root)
40+
items = _query_bazel_tests(workspace_root)
4041
except (subprocess.TimeoutExpired, FileNotFoundError):
41-
pass
42-
return _walk_test_files(workspace_root)
42+
items = None
43+
if items is None:
44+
items = _walk_test_files(workspace_root)
45+
46+
# Both discovery methods see every py_test in the workspace. Keep only the
47+
# gameplay tests, identified by their use of the gameplay_test_runner fixture.
48+
return [it for it in items if _uses_gameplay_fixture(it.src_file)]
49+
50+
51+
def _uses_gameplay_fixture(src_file: str) -> bool:
52+
"""Return True if the test file uses the ``gameplay_test_runner`` fixture.
53+
54+
:param src_file: Path to the test file
55+
"""
56+
try:
57+
with open(src_file) as f:
58+
return "gameplay_test_runner" in f.read()
59+
except OSError:
60+
return False
4361

4462

4563
def _query_bazel_tests(workspace_root: str) -> list[TestItem]:
@@ -91,16 +109,16 @@ def _walk_test_files(workspace_root: str) -> list[TestItem]:
91109

92110

93111
def _make_item(label: str, name: str, src_file: str) -> TestItem:
94-
"""Build a TestItem from a test label/name, classifying as field vs simulated."""
95-
if "field_test" in name.lower():
96-
test_type = "field"
97-
else:
98-
test_type = "simulated"
112+
"""Build a TestItem from a test label/name.
113+
114+
All gameplay tests are classified as ``simulated`` -- even ``*_field_test.py``
115+
files -- since they can all be run against the in-Thunderscope simulator.
116+
"""
99117
return TestItem(
100118
target_label=label,
101119
test_name=name,
102120
src_file=src_file,
103-
test_type=test_type,
121+
test_type="simulated",
104122
)
105123

106124

src/software/thunderscope/common/test_selector_widget.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import threading
22

3-
from pyqtgraph.Qt.QtCore import QTimer, Qt
3+
from pyqtgraph.Qt.QtCore import Qt, pyqtSignal
44
from pyqtgraph.Qt.QtWidgets import *
55

66
from software.thunderscope.common.test_discovery import (
@@ -14,6 +14,12 @@
1414
class TestSelectorWidget(QWidget):
1515
"""Widget that lets users discover and run gameplay tests within Thunderscope."""
1616

17+
# Signals used to marshal updates from worker threads (test scanning and the
18+
# test bridge) back onto the GUI thread. Touching widgets off the GUI thread
19+
# is undefined behaviour in Qt.
20+
_status_signal = pyqtSignal(str)
21+
_scan_finished_signal = pyqtSignal()
22+
1723
def __init__(self, parent=None):
1824
super().__init__(parent)
1925
self._all_tests: list[TestItem] = []
@@ -25,6 +31,8 @@ def __init__(self, parent=None):
2531
self._expanded_targets: dict[str, list[TestItem]] = {}
2632

2733
self._setup_ui()
34+
self._status_signal.connect(self.status_label.setText)
35+
self._scan_finished_signal.connect(self._on_scan_finished)
2836
self._start_scan()
2937

3038
def _setup_ui(self):
@@ -81,6 +89,7 @@ def _start_scan(self):
8189
thread.start()
8290

8391
def _scan_tests(self):
92+
# Runs on a worker thread: only touch plain data here, never widgets.
8493
items = discover_test_targets()
8594
seen_targets = set()
8695
deduped = []
@@ -90,13 +99,17 @@ def _scan_tests(self):
9099
deduped.append(it)
91100
self._all_tests = deduped
92101
self._scanning = False
102+
self._scan_finished_signal.emit()
103+
104+
def _on_scan_finished(self):
105+
# Runs on the GUI thread (queued from _scan_tests).
93106
self.refresh_button.setEnabled(True)
94-
if not deduped:
107+
self._rebuild_tree()
108+
if not self._all_tests:
95109
self.status_label.setText(
96110
"Failed to discover tests (bazel query blocked, "
97111
"used filesystem fallback but found nothing)"
98112
)
99-
QTimer.singleShot(0, self._rebuild_tree)
100113

101114
def _on_search(self, text):
102115
self._rebuild_tree()
@@ -173,7 +186,9 @@ def _run_test(self, item: TestItem):
173186
self._bridge.run_test(item)
174187

175188
def _on_test_completed(self, node_id: str, passed: bool, message: str):
189+
# Invoked from the test bridge's worker thread, so route the status
190+
# update through a signal to reach the GUI thread safely.
176191
if passed:
177-
self.status_label.setText(f"{node_id} \u2713 PASSED")
192+
self._status_signal.emit(f"{node_id} \u2713 PASSED")
178193
else:
179-
self.status_label.setText(f"{node_id} \u2717 FAILED: {message}")
194+
self._status_signal.emit(f"{node_id} \u2717 FAILED: {message}")

src/software/thunderscope/proto_unix_io.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,26 @@ def register_observer(
9696
else:
9797
self.proto_observers[proto_class.DESCRIPTOR.full_name] = [buffer]
9898

99+
def unregister_observer(
100+
self, proto_class: Type[Message], buffer: ThreadSafeBuffer
101+
) -> None:
102+
"""Unregister a previously registered buffer for a given protobuf class.
103+
104+
Used by short-lived consumers (e.g. test runners launched repeatedly from
105+
the test selector widget) to avoid leaking stale buffers on a ProtoUnixIO
106+
that outlives them.
107+
108+
:param proto_class: Class of protobuf the buffer was registered to consume
109+
:param buffer: the buffer to remove
110+
"""
111+
observers = self.proto_observers.get(proto_class.DESCRIPTOR.full_name)
112+
if observers:
113+
# Replace the list rather than mutating it in place so that a
114+
# concurrent __send_proto_to_observers iteration stays safe.
115+
self.proto_observers[proto_class.DESCRIPTOR.full_name] = [
116+
b for b in observers if b is not buffer
117+
]
118+
99119
def register_to_observe_everything(self, buffer: ThreadSafeBuffer) -> None:
100120
"""Register a buffer to observe all incoming protobufs
101121

0 commit comments

Comments
 (0)