33"""
44
55import asyncio
6+ import os
7+ import threading
68import time
7- from collections .abc import Mapping
9+ from collections .abc import AsyncGenerator , Mapping
810
911import pytest
12+ import pytest_asyncio
13+ from _pytest .fixtures import FixtureRequest
1014from bluesky .run_engine import RunEngine
1115from bluesky .simulators import RunEngineSimulator
1216
1317_run_engine = RunEngine ()
1418
19+ _ENABLE_FILEHANDLE_LEAK_CHECKS = (
20+ os .getenv ("DODAL_ENABLE_FILEHANDLE_LEAK_CHECKS" , "" ).lower () == "true"
21+ )
1522
16- @pytest .fixture (scope = "session" , autouse = True )
17- async def _ensure_running_bluesky_event_loop ():
23+
24+ @pytest_asyncio .fixture (scope = "session" , loop_scope = "session" , autouse = True )
25+ async def _ensure_running_bluesky_event_loop (_global_run_engine ):
1826 # make sure the event loop is thoroughly up and running before we try to create
1927 # any ophyd_async devices which might need it
2028 timeout = time .monotonic () + 1
21- while not _run_engine .loop .is_running ():
29+ while not _global_run_engine .loop .is_running ():
2230 await asyncio .sleep (0 )
2331 if time .monotonic () > timeout :
2432 raise TimeoutError ("This really shouldn't happen but just in case..." )
2533
2634
2735@pytest .fixture ()
28- def run_engine ():
29- global _run_engine
30- _run_engine .reset ()
31- return _run_engine
36+ async def run_engine (_global_run_engine : RunEngine ) -> AsyncGenerator [RunEngine , None ]:
37+ try :
38+ yield _global_run_engine
39+ finally :
40+ _global_run_engine .reset ()
41+
42+
43+ @pytest_asyncio .fixture (scope = "session" , loop_scope = "session" )
44+ async def _global_run_engine () -> AsyncGenerator [RunEngine , None ]:
45+ """
46+ Obtain a run engine, with its own event loop and thread.
47+
48+ On closure of the scope, the run engine is stopped and the event loop closed
49+ in order to release all resources it consumes.
50+ """
51+ run_engine = RunEngine ({}, call_returns_result = True )
52+ yield run_engine
53+ try :
54+ run_engine .halt ()
55+ except Exception as e :
56+ # Ignore exception thrown if the run engine is already halted.
57+ print (f"Got exception while halting RunEngine { e } " )
58+ finally :
59+
60+ async def get_event_loop_thread ():
61+ """Get the thread which the run engine created for the event loop."""
62+ return threading .current_thread ()
63+
64+ fut = asyncio .run_coroutine_threadsafe (get_event_loop_thread (), run_engine .loop )
65+ while not fut .done ():
66+ # It's not clear why this is necessary, given we are
67+ # on a completely different thread and event loop
68+ # but without it our future never seems to be populated with a result
69+ # despite the coro getting executed
70+ await asyncio .sleep (0 )
71+ # Terminate the event loop so that we can join() the thread
72+ run_engine .loop .call_soon_threadsafe (run_engine .loop .stop )
73+ run_engine_thread = fut .result ()
74+ run_engine_thread .join ()
75+ # This closes the filehandle in the event loop.
76+ # This cannot be called while the event loop is running
77+ run_engine .loop .close ()
78+ del run_engine
3279
3380
3481@pytest .fixture
@@ -47,3 +94,25 @@ def append_and_print(name, doc):
4794
4895 run_engine .subscribe (append_and_print )
4996 return docs
97+
98+
99+ @pytest .fixture (autouse = _ENABLE_FILEHANDLE_LEAK_CHECKS )
100+ def check_for_filehandle_leaks (request : FixtureRequest ):
101+ """
102+ Test fixture that can be enabled in order to check for leaked filehandles
103+ (typically caused by a rogue RunEngine instance).
104+
105+ Note that this test is not enabled by default due to imposing a significant
106+ overhead. When a leak is suspected, usually from seeing a
107+ PytestUnraisableExceptionWarning, enable this via autouse and run the full
108+ test suite.
109+ """
110+ pid = os .getpid ()
111+ _baseline_n_open_files = len (os .listdir (f"/proc/{ pid } /fd" ))
112+ try :
113+ yield
114+ finally :
115+ _n_open_files = len (os .listdir (f"/proc/{ pid } /fd" ))
116+ assert _n_open_files == _baseline_n_open_files , (
117+ f"Function { request .function .__name__ } leaked some filehandles"
118+ )
0 commit comments