55import functools
66import threading
77from collections .abc import Callable , Coroutine , Iterator
8- from contextlib import AbstractAsyncContextManager , AbstractContextManager , contextmanager
8+ from contextlib import AbstractAsyncContextManager , AbstractContextManager , contextmanager , suppress
99from types import TracebackType
1010from typing import Any , Concatenate , Generic , ParamSpec , TypeVar
1111
@@ -27,15 +27,18 @@ def __init__(self) -> None:
2727 )
2828 self ._thread .start ()
2929 self ._stopping = False
30+ # orders scheduling against stop: nothing reaches the loop once it is stopping
31+ self ._lock = threading .Lock ()
3032
3133 def run (self , coro : Coroutine [Any , Any , R ]) -> R :
3234 if threading .current_thread () is self ._thread :
3335 coro .close ()
3436 raise RuntimeError ("a blocking call on the client's own loop would deadlock" )
35- if self ._stopping :
36- coro .close ()
37- raise Disconnected ("the client is closed" )
38- future = asyncio .run_coroutine_threadsafe (coro , self ._loop )
37+ with self ._lock :
38+ if self ._stopping :
39+ coro .close ()
40+ raise Disconnected ("the client is closed" )
41+ future = asyncio .run_coroutine_threadsafe (coro , self ._loop )
3942 try :
4043 return future .result ()
4144 except concurrent .futures .CancelledError :
@@ -51,16 +54,25 @@ def stopped(self) -> bool:
5154 return self ._stopping
5255
5356 def stop (self ) -> None :
54- """Cancel what still runs, so a call waiting in another thread fails instead of hanging."""
55- self ._stopping = True
57+ """Cancel what still runs, so a call waiting in another thread fails instead of hanging.
58+
59+ Safe from any thread and more than once; every caller returns once the loop is down."""
5660
5761 async def cancel_all () -> None :
5862 tasks = asyncio .all_tasks () - {asyncio .current_task ()}
5963 for task in tasks :
6064 task .cancel ()
6165 await asyncio .gather (* tasks , return_exceptions = True )
6266
63- asyncio .run_coroutine_threadsafe (cancel_all (), self ._loop ).result ()
67+ with self ._lock :
68+ cancelled = None
69+ if not self ._stopping :
70+ self ._stopping = True
71+ cancelled = asyncio .run_coroutine_threadsafe (cancel_all (), self ._loop )
72+ if cancelled is None : # another thread is stopping it
73+ self ._thread .join ()
74+ return
75+ cancelled .result ()
6476 self ._loop .call_soon_threadsafe (self ._loop .stop )
6577 self ._thread .join ()
6678 self ._loop .close ()
@@ -162,8 +174,9 @@ def connect(
162174 def close (self ) -> None :
163175 """Close the connection; breakpoints stay armed in PPSSPP."""
164176 if not self ._loop .stopped :
165- self ._loop .run (self ._async .close ())
166- self ._loop .stop ()
177+ with suppress (Disconnected ): # another thread closed it meanwhile
178+ self ._loop .run (self ._async .close ())
179+ self ._loop .stop ()
167180
168181 def __enter__ (self ) -> Client :
169182 return self
0 commit comments