Skip to content

Commit 26a5f52

Browse files
committed
idk
1 parent c657aec commit 26a5f52

7 files changed

Lines changed: 350 additions & 174 deletions

File tree

src/software/gameplay_tests/simulated_test_runner.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
LAUNCH_DELAY_S = 0.1
1919
WORLD_BUFFER_TIMEOUT = 0.5
2020
TICK_DURATION_S = DEFAULT_SIMULATOR_TICK_RATE_SECONDS_PER_TICK
21+
# Upper bound on how long _pre_run_setup will retry waiting for the simulator to
22+
# acknowledge the world state before giving up. The simulator normally responds
23+
# within WORLD_BUFFER_TIMEOUT, so this only trips on a genuinely wedged sim,
24+
# preventing an infinite setup loop from hanging an externally driven test.
25+
SETUP_TIMEOUT_S = 30
2126

2227

2328
class SimulatedTestRunner(TbotsTestRunner):
@@ -77,6 +82,7 @@ def _pre_run_setup(self, setup: (lambda: None)):
7782
world_state_received_buffer,
7883
)
7984

85+
setup_start_time = time.time()
8086
while True:
8187
setup()
8288

@@ -86,6 +92,11 @@ def _pre_run_setup(self, setup: (lambda: None)):
8692
)
8793
except queue.Empty:
8894
# Did not receive a response within timeout period
95+
if time.time() - setup_start_time > SETUP_TIMEOUT_S:
96+
raise TimeoutError(
97+
"Simulator did not acknowledge the world state within "
98+
f"{SETUP_TIMEOUT_S}s during test setup"
99+
)
89100
continue
90101
else:
91102
# Received a response from the simulator

src/software/gameplay_tests/tbots_test_runner.py

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
PAUSE_AFTER_FAIL_DELAY_S = 3
1212
PROCESS_BUFFER_DELAY_S = 0.01
13+
# Extra wall-clock grace, on top of a test's own timeout, before an externally
14+
# driven test (run from the test selector widget) is declared hung. Generous so
15+
# it only ever trips on a genuinely wedged simulator, never on a slow machine.
16+
EXTERNAL_TEST_GRACE_S = 30
1317

1418

1519
class TbotsTestRunner:
@@ -43,6 +47,7 @@ def __init__(
4347
self.yellow_full_system_proto_unix_io = yellow_full_system_proto_unix_io
4448
self.gamecontroller = gamecontroller
4549
self.is_yellow_friendly = is_yellow_friendly
50+
self._test_thread = None
4651
self.world_buffer = ThreadSafeBuffer(buffer_size=20, protobuf_type=World)
4752
self.primitive_set_buffer = ThreadSafeBuffer(
4853
buffer_size=1, protobuf_type=PrimitiveSet
@@ -188,6 +193,11 @@ def run_test(
188193
"""
189194
self._pre_run_setup(setup)
190195

196+
# Install our thread excepthook so a failure on the test thread is
197+
# captured into last_exception, and restore the previous hook afterwards
198+
# so we don't leave a stale, short-lived runner installed on the
199+
# long-lived process (e.g. the --test_mode GUI).
200+
previous_excepthook = threading.excepthook
191201
threading.excepthook = self._excepthook
192202

193203
args = (
@@ -197,23 +207,38 @@ def run_test(
197207
gc_cmd_with_delay,
198208
)
199209

200-
if self.thunderscope_is_external:
201-
self._test_thread = threading.Thread(
202-
target=self._runner, daemon=True, args=args
203-
)
204-
self._test_thread.start()
205-
elif self.thunderscope:
206-
run_test_thread = threading.Thread(
207-
target=self._runner, daemon=True, args=args
208-
)
209-
run_test_thread.start()
210-
self.thunderscope.show()
211-
run_test_thread.join()
212-
213-
if self.last_exception:
214-
pytest.fail(str(self.last_exception))
215-
else:
216-
self._runner(*args)
210+
try:
211+
if self.thunderscope_is_external:
212+
# Thunderscope is owned/shown by an external owner (the test
213+
# selector widget). We are already off the GUI thread here, so
214+
# block until the test finishes to give run_test the same
215+
# synchronous semantics as the other modes (callers may validate
216+
# after run_test, or call run_test more than once). Bound the
217+
# wait so a wedged simulator can't hang the caller forever.
218+
self._test_thread = threading.Thread(
219+
target=self._runner, daemon=True, args=args
220+
)
221+
self._test_thread.start()
222+
self._test_thread.join(timeout=test_timeout_s + EXTERNAL_TEST_GRACE_S)
223+
if self._test_thread.is_alive():
224+
raise TimeoutError(
225+
f"Test '{self.test_name}' did not finish within "
226+
f"{test_timeout_s + EXTERNAL_TEST_GRACE_S:.0f}s"
227+
)
228+
elif self.thunderscope:
229+
run_test_thread = threading.Thread(
230+
target=self._runner, daemon=True, args=args
231+
)
232+
run_test_thread.start()
233+
self.thunderscope.show()
234+
run_test_thread.join()
235+
236+
if self.last_exception:
237+
pytest.fail(str(self.last_exception))
238+
else:
239+
self._runner(*args)
240+
finally:
241+
threading.excepthook = previous_excepthook
217242

218243
@abstractmethod
219244
def _runner(
@@ -237,19 +262,26 @@ def _pre_run_setup(self, setup: (lambda: None)):
237262
"""
238263
setup()
239264

265+
def ran_a_test(self) -> bool:
266+
"""Whether run_test() has actually started a test thread on this runner.
267+
268+
:return: True if a test was launched (in external mode)
269+
"""
270+
return self._test_thread is not None
271+
240272
def is_test_running(self) -> bool:
241273
"""Check if a test is currently running in external mode.
242274
243275
:return: True if a test thread is alive
244276
"""
245-
return hasattr(self, "_test_thread") and self._test_thread.is_alive()
277+
return self._test_thread is not None and self._test_thread.is_alive()
246278

247279
def wait_for_test(self, timeout: float | None = None) -> None:
248280
"""Wait for the external test thread to finish.
249281
250282
:param timeout: Maximum seconds to wait, or None for no timeout
251283
"""
252-
if hasattr(self, "_test_thread") and self._test_thread.is_alive():
284+
if self._test_thread is not None and self._test_thread.is_alive():
253285
self._test_thread.join(timeout=timeout)
254286

255287
def _stopper(self, delay=PROCESS_BUFFER_DELAY_S):
@@ -270,7 +302,15 @@ def _excepthook(self, args):
270302
271303
:param args: The args passed in from the hook
272304
"""
273-
self._stopper(delay=PAUSE_AFTER_FAIL_DELAY_S)
305+
# The post-fail pause exists to let the user see the failure before the
306+
# runner closes Thunderscope. When Thunderscope is owned externally we
307+
# never close it, so there's nothing to wait for.
308+
delay = (
309+
PROCESS_BUFFER_DELAY_S
310+
if self.thunderscope_is_external
311+
else PAUSE_AFTER_FAIL_DELAY_S
312+
)
313+
self._stopper(delay=delay)
274314
self.last_exception = args.exc_value
275315
raise self.last_exception
276316

src/software/thunderscope/common/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ py_library(
7575
name = "test_bridge",
7676
srcs = ["test_bridge.py"],
7777
deps = [
78+
":test_discovery",
7879
"//software/thunderscope:constants",
7980
],
8081
)

src/software/thunderscope/common/test_bridge.py

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import importlib
2-
import importlib.util
3-
import sys
41
import threading
52

3+
from software.thunderscope.common.test_discovery import load_test_module
64
from software.thunderscope.constants import ProtoUnixIOTypes
75

86

@@ -24,6 +22,7 @@ def __init__(self):
2422
self.gamecontroller = None
2523
self.thunderscope = None
2624
self._running = False
25+
self._lock = threading.Lock()
2726
self._current_runner = None
2827
self._on_test_completed = None
2928

@@ -53,8 +52,6 @@ def run_test(self, item):
5352
5453
:param item: TestItem from test_discovery
5554
"""
56-
if self._running:
57-
return
5855
if item.test_type != "simulated":
5956
if self._on_test_completed:
6057
self._on_test_completed(
@@ -63,7 +60,12 @@ def run_test(self, item):
6360
"Field tests not supported in --test_mode yet",
6461
)
6562
return
66-
self._running = True
63+
# Atomically claim the "running" slot so two near-simultaneous starts
64+
# can't both spawn a runner against the shared simulator.
65+
with self._lock:
66+
if self._running:
67+
return
68+
self._running = True
6769
thread = threading.Thread(
6870
target=self._execute_test, args=(item,), daemon=True
6971
)
@@ -75,8 +77,9 @@ def _execute_test(self, item):
7577
)
7678

7779
node_id = item.node_id or item.function_name or item.test_name
80+
runner = None
7881
try:
79-
module = self._import_test_module(item)
82+
module = load_test_module(item.src_file)
8083
test_func = getattr(module, item.function_name)
8184
runner = SimulatedTestRunner(
8285
test_name=node_id,
@@ -101,10 +104,16 @@ def _execute_test(self, item):
101104
else:
102105
test_func(gameplay_test_runner=runner)
103106

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+
# In external mode run_test() blocks until the test finishes (we are
108+
# off the GUI thread), so this is a no-op guard. Treat a test
109+
# function that never started a test as a failure rather than a
110+
# silent pass.
107111
runner.wait_for_test()
112+
if not runner.ran_a_test():
113+
raise RuntimeError(
114+
"Test function returned without running a test "
115+
"(run_test was never called)"
116+
)
108117
if runner.last_exception is not None:
109118
raise runner.last_exception
110119

@@ -114,17 +123,7 @@ def _execute_test(self, item):
114123
if self._on_test_completed:
115124
self._on_test_completed(node_id, False, str(e))
116125
finally:
117-
if self._current_runner is not None:
118-
self._current_runner.unregister_observers()
119-
self._running = False
126+
if runner is not None:
127+
runner.unregister_observers()
120128
self._current_runner = None
121-
122-
def _import_test_module(self, item):
123-
"""Dynamically import the test module from its source file."""
124-
spec = importlib.util.spec_from_file_location(item.test_name, item.src_file)
125-
if spec is None:
126-
raise ImportError(f"Could not load spec for {item.src_file}")
127-
module = importlib.util.module_from_spec(spec)
128-
sys.modules[item.test_name] = module
129-
spec.loader.exec_module(module)
130-
return module
129+
self._running = False

0 commit comments

Comments
 (0)