Skip to content

Commit 382dcfb

Browse files
authored
Merge pull request #47 from MHFU-ModKit/fix/client-close-race
fix(ppsspp-debug): Client.close() is safe from several threads
2 parents cd72733 + 51c7868 commit 382dcfb

2 files changed

Lines changed: 44 additions & 10 deletions

File tree

‎packages/ppsspp-debug/src/ppsspp_debug/_sync.py‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import functools
66
import threading
77
from collections.abc import Callable, Coroutine, Iterator
8-
from contextlib import AbstractAsyncContextManager, AbstractContextManager, contextmanager
8+
from contextlib import AbstractAsyncContextManager, AbstractContextManager, contextmanager, suppress
99
from types import TracebackType
1010
from 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

‎packages/ppsspp-debug/tests/test_sync.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ def wait():
9090
c.status()
9191

9292

93+
def test_close_from_many_threads(served):
94+
errors: list[BaseException] = []
95+
96+
def close(c: Client) -> None:
97+
try:
98+
c.close()
99+
except BaseException as e:
100+
errors.append(e)
101+
102+
for _ in range(40):
103+
c = Client.connect(port=served.fake.port)
104+
closers = [threading.Thread(target=close, args=(c,), daemon=True) for _ in range(4)]
105+
for t in closers:
106+
t.start()
107+
for t in closers:
108+
t.join(2)
109+
assert not any(t.is_alive() for t in closers)
110+
assert c.closed
111+
assert errors == []
112+
113+
93114
def test_every_async_method_is_mirrored():
94115
public = {n for n, v in vars(AsyncClient).items() if not n.startswith("_") and callable(v)}
95116
assert public <= set(vars(Client))

0 commit comments

Comments
 (0)