1010
1111PAUSE_AFTER_FAIL_DELAY_S = 3
1212PROCESS_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
1519class 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
0 commit comments