Skip to content

Commit 3aaba71

Browse files
committed
Fix PythonHere app asyncio lifecycle
1 parent d5cca97 commit 3aaba71

3 files changed

Lines changed: 256 additions & 44 deletions

File tree

pythonhere/main.py

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""PythonHere app."""
2+
23
# pylint: disable=wrong-import-order,wrong-import-position
34

45
from launcher_here import try_startup_script
@@ -35,13 +36,25 @@ class PythonHereApp(App):
3536

3637
def __init__(self):
3738
super().__init__()
38-
self.server_task = None
39+
self.server_task: asyncio.Task | None = None
40+
self.app_task: asyncio.Task | None = None
41+
self.asyncio_loop: asyncio.AbstractEventLoop | None = None
3942
self.settings = None
43+
44+
# Created once a running asyncio loop exists.
45+
self.ssh_server_config_ready: asyncio.Event | None = None
46+
self.ssh_server_started: asyncio.Event | None = None
47+
self.ssh_server_connected: asyncio.Event | None = None
48+
49+
self.ssh_server_namespace = {}
50+
self.icon = "data/logo/logo-32.png"
51+
52+
def init_asyncio_state(self):
53+
"""Initialize asyncio-owned state after the event loop is running."""
54+
self.asyncio_loop = asyncio.get_running_loop()
4055
self.ssh_server_config_ready = asyncio.Event()
4156
self.ssh_server_started = asyncio.Event()
4257
self.ssh_server_connected = asyncio.Event()
43-
self.ssh_server_namespace = {}
44-
self.icon = "data/logo/logo-32.png"
4558

4659
@property
4760
def upload_dir(self) -> str:
@@ -66,9 +79,7 @@ def build(self):
6679
"""Initialize application UI."""
6780
super().build()
6881
install_exception_handler()
69-
7082
self.settings = self.root.ids.settings
71-
7283
self.ssh_server_namespace.update(
7384
{
7485
"app": self,
@@ -81,47 +92,81 @@ def build(self):
8192
lambda _: show_exception_popup(startup_script_exception), 0
8293
)
8394

84-
def run_app(self):
85-
"""Run application and SSH server tasks."""
86-
self.ssh_server_started = asyncio.Event()
87-
self.server_task = asyncio.ensure_future(run_ssh_server(self))
88-
return asyncio.gather(self.async_run_app(), self.server_task)
95+
async def run_app(self):
96+
"""Run application and SSH server until either main task exits."""
97+
self.init_asyncio_state()
98+
99+
self.server_task = asyncio.create_task(run_ssh_server(self))
100+
self.app_task = asyncio.create_task(self.async_run_app())
101+
102+
tasks = (self.server_task, self.app_task)
103+
104+
try:
105+
done, pending = await asyncio.wait(
106+
tasks,
107+
return_when=asyncio.FIRST_COMPLETED,
108+
)
109+
110+
for task in pending:
111+
task.cancel()
112+
113+
if pending:
114+
await asyncio.gather(*pending, return_exceptions=True)
115+
116+
for task in done:
117+
if task.cancelled():
118+
continue
119+
exc = task.exception()
120+
if exc is not None:
121+
raise exc
122+
finally:
123+
await self.cancel_app_tasks()
89124

90125
async def async_run_app(self):
91126
"""Run app asynchronously."""
92127
try:
93-
await self.async_run(async_lib="asyncio")
128+
await self.async_run()
94129
Logger.info("PythonHere: async run completed")
95130
except asyncio.CancelledError:
96131
Logger.info("PythonHere: app main task canceled")
132+
raise
97133
except Exception as exc:
98134
Logger.exception(exc)
135+
raise
136+
finally:
137+
if self.get_running_app():
138+
self.stop()
99139

100-
if self.server_task:
101-
self.server_task.cancel()
102-
103-
if self.get_running_app():
104-
self.stop()
105-
106-
await self.cancel_asyncio_tasks()
107-
108-
async def cancel_asyncio_tasks(self):
109-
"""Cancel all asyncio tasks."""
140+
async def cancel_app_tasks(self):
141+
"""Cancel tasks owned by this app."""
110142
tasks = [
111-
task for task in asyncio.all_tasks() if task is not asyncio.current_task()
143+
task
144+
for task in (self.server_task, self.app_task)
145+
if task is not None
146+
and task is not asyncio.current_task()
147+
and not task.done()
112148
]
149+
150+
for task in tasks:
151+
task.cancel()
152+
113153
if tasks:
114-
for task in tasks:
115-
task.cancel()
116-
await asyncio.wait(tasks, timeout=1)
154+
await asyncio.gather(*tasks, return_exceptions=True)
117155

118156
def update_server_config_status(self):
119157
"""Check and update value of the `ssh_server_config_ready`, update screen."""
120158

121159
def update():
122160
if all(self.get_pythonhere_config().values()):
123-
self.ssh_server_config_ready.set()
124-
screen.update()
161+
if (
162+
self.asyncio_loop is not None
163+
and self.ssh_server_config_ready is not None
164+
):
165+
self.asyncio_loop.call_soon_threadsafe(
166+
self.ssh_server_config_ready.set
167+
)
168+
169+
Clock.schedule_once(lambda _: screen.update(), 0)
125170

126171
screen = self.root.ids.here_screen_manager
127172
screen.current = ServerState.starting_server
@@ -151,8 +196,14 @@ def on_pause(self):
151196
def on_ssh_connection_made(self):
152197
"""New authenticated SSH client connected handler."""
153198
Logger.info("PythonHere: new SSH client connected")
199+
200+
if self.ssh_server_connected is None:
201+
Logger.warning("PythonHere: SSH connected before asyncio state was ready")
202+
return
203+
154204
if not self.ssh_server_connected.is_set():
155205
self.ssh_server_connected.set()
206+
156207
Logger.info("PythonHere: reset window environment")
157208
self.ssh_server_namespace["root"] = reset_window_environment()
158209
self.chdir(self.upload_dir)
@@ -164,7 +215,11 @@ def chdir(self, path: str):
164215
sys.path.insert(0, path)
165216

166217

218+
async def main():
219+
"""Run PythonHere."""
220+
app = PythonHereApp()
221+
await app.run_app()
222+
223+
167224
if __name__ == "__main__":
168-
loop = asyncio.get_event_loop()
169-
loop.run_until_complete(PythonHereApp().run_app())
170-
loop.close()
225+
asyncio.run(main())

tests/conftest.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import os
33
import sys
4+
from contextlib import suppress
45
from pathlib import Path
56

67
import nest_asyncio
@@ -31,16 +32,12 @@ def app_config():
3132

3233
@pytest.fixture
3334
async def app_instance(mocker, capfd, app_config, tmpdir):
34-
35-
async def nop():
36-
pass
37-
3835
mocker.patch("main.App.user_data_dir", tmpdir)
3936

4037
app = PythonHereApp()
38+
app.init_asyncio_state()
4139
app._on_ssh_connection_made = app.on_ssh_connection_made
4240
app.on_ssh_connection_made = mocker.Mock()
43-
app.cancel_asyncio_tasks = nop
4441

4542
app_task = asyncio.ensure_future(app.async_run_app())
4643
server_task = asyncio.ensure_future(run_ssh_server(app))
@@ -49,7 +46,12 @@ async def nop():
4946

5047
server_task.cancel()
5148
app_task.cancel()
52-
await asyncio.gather(app_task, server_task)
49+
results = await asyncio.gather(app_task, server_task, return_exceptions=True)
50+
for result in results:
51+
if isinstance(result, BaseException) and not isinstance(
52+
result, asyncio.CancelledError
53+
):
54+
raise result
5355
app.root.clear_widgets()
5456
Window.children.clear()
5557

@@ -59,7 +61,14 @@ async def there(app_instance, connection_config):
5961
client = Client()
6062
await asyncio.wait_for(app_instance.ssh_server_started.wait(), 5)
6163
await client.connect(connection_config)
62-
yield client
64+
try:
65+
yield client
66+
finally:
67+
connection = client.connection.connection
68+
await client.disconnect()
69+
if connection is not None:
70+
with suppress(Exception):
71+
await connection.wait_closed()
6372

6473

6574
@pytest.fixture
@@ -73,7 +82,7 @@ async def there_with_wrong_password(app_instance, connection_config):
7382

7483

7584
@pytest.fixture
76-
def nested_event_loop(event_loop):
85+
def nested_event_loop():
7786
nest_asyncio.apply()
7887

7988

0 commit comments

Comments
 (0)