Skip to content

Commit 88550e2

Browse files
Cleaner shutdown on Windows (#549)
* Work around signals on Windows (fixes #548) As discussed in the issue above, this works around signals not working properly on Windows by setting up an endpoint that shuts down the server. * Update requirements.txt A few required packages were not named, and updated pytest. * Fix _stop_dev_server when _session isn't set * Get tests working * Added CHANGES file * Fixups for linter * Skip a test on Python 3.7 * Delete 548.bugfix * A workaround for issue #565 This change allows the tests to run multiple times without leaving processes behind or leaving port 8000 open. * Revert "Update requirements.txt" This reverts commit 778ddbe. This is now taken care of by my pull request #564. * Apply a suggestion from code review Co-authored-by: Sam Bull <[email protected]> * Apply a suggestion from code review Co-authored-by: Sam Bull <[email protected]> * The issue #565 workaround needs to be adjusted Because in this branch we've changed _stop_dev_server to be async * Added a test for handlers Tests whether on_startup, cleanup_ctx, and on_shutdown handlers are called. * Revert "A workaround for issue #565" This reverts commit 11eab74. * Fix #565 Co-authored-by: Sam Bull <[email protected]> * Remove an unused function * Turn off fail-fast on tests * Hopefully make linter happy * test_runserver_cleanup.py needs the #566 patch * Copy over a filterwarnings to try and fix failures * Fix requirements-dev.txt on Python 3.7 * Began work on test_server_cleanup_byurl (unfinished) * Revert "Began work on test_server_cleanup_byurl (unfinished)" This reverts commit 54eced8. * Working shutdown/cleanup code The manual test in cleanup_app.py passed on Windows 10, Python 3.7 through 3.11. * The two tests marked non_windows_test now work * Revert "Turn off fail-fast on tests" This reverts commit 15317f3. Not needed anymore now that all tests are passing * Revert "Fix requirements-dev.txt on Python 3.7" This reverts commit dcbe025. PR #569 was rejected and I mainly needed it for testing. * Make linter happy again * Update aiohttp_devtools/runserver/watch.py * Revert af852e0, thereby restoring 54eced8 This reverts commit af852e0, which reverted commit 54eced8 "Began work on test_server_cleanup_byurl (unfinished)" because it turns out we can use that code as a basis to test on Linux. * Added --shutdown-by-url to CLI * Replaced test_server_cleanup with _byurl version * Update watch.py * Update cli.py * Update config.py * Update serve.py * Update cleanup_app.py * Update test_runserver_cleanup.py * Update test_runserver_cleanup.py * Update watch.py * Update cleanup_app.py --------- Co-authored-by: Sam Bull <[email protected]>
1 parent da2e8e7 commit 88550e2

File tree

9 files changed

+153
-33
lines changed

9 files changed

+153
-33
lines changed

aiohttp_devtools/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def serve(path: str, livereload: bool, port: int, verbose: bool) -> None:
4646
'This can be used to watch a parent directory for changes in a more complex application. '
4747
'env variable: AIO_ROOT')
4848
static_url_help = 'URL path to serve static files from, default "/static/". env variable: AIO_STATIC_URL'
49+
shutdown_by_url_help = ("The development server will be stopped via a request to an endpoint that is "
50+
"added to the server, instead of via signals (this is the default on Windows). "
51+
"env variable: AIO_SHUTDOWN_BY_URL")
4952
host_help = ('host used when referencing livereload and static files, if blank host is taken from the request header '
5053
'with default of localhost. env variable AIO_HOST')
5154
app_factory_help = ('name of the app factory to create an aiohttp.web.Application with, if missing default app-factory '
@@ -62,6 +65,8 @@ def serve(path: str, livereload: bool, port: int, verbose: bool) -> None:
6265
@click.option('-s', '--static', 'static_path', envvar='AIO_STATIC_PATH', type=_dir_existing, help=static_help)
6366
@click.option('--root', 'root_path', envvar='AIO_ROOT', type=_dir_existing, help=root_help)
6467
@click.option('--static-url', envvar='AIO_STATIC_URL', help=static_url_help)
68+
@click.option("--shutdown-by-url/--no-shutdown-by-url", default=sys.platform.startswith("win32"),
69+
envvar="AIO_SHUTDOWN_BY_URL", help=shutdown_by_url_help)
6570
@click.option('--livereload/--no-livereload', envvar='AIO_LIVERELOAD', default=None, help=livereload_help)
6671
@click.option('--host', default=INFER_HOST, help=host_help)
6772
@click.option('--app-factory', 'app_factory_name', envvar='AIO_APP_FACTORY', help=app_factory_help)

aiohttp_devtools/runserver/config.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ def __init__(self, *,
3838
python_path: Optional[str] = None,
3939
static_url: str = '/static/',
4040
livereload: bool = True,
41+
shutdown_by_url: bool = sys.platform.startswith("win32"),
42+
path_prefix: str = "/_devtools",
4143
app_factory_name: Optional[str] = None,
4244
host: str = INFER_HOST,
4345
main_port: int = 8000,
@@ -66,6 +68,8 @@ def __init__(self, *,
6668
self.static_path = self._resolve_path(static_path, "is_dir", "static-path") if static_path else None
6769
self.static_url = static_url
6870
self.livereload = livereload
71+
self.shutdown_by_url = shutdown_by_url
72+
self.path_prefix = path_prefix
6973
self.app_factory_name = app_factory_name
7074
self.infer_host = host == INFER_HOST
7175
self.host = 'localhost' if self.infer_host else host
@@ -183,6 +187,6 @@ async def load_app(self, app_factory: AppFactory) -> web.Application:
183187
return app
184188

185189
def __str__(self) -> str:
186-
fields = ('py_file', 'static_path', 'static_url', 'livereload',
187-
'app_factory_name', 'host', 'main_port', 'aux_port')
190+
fields = ("py_file", "static_path", "static_url", "livereload", "shutdown_by_url",
191+
"path_prefix", "app_factory_name", "host", "main_port", "aux_port")
188192
return 'Config:\n' + '\n'.join(' {0}: {1!r}'.format(f, getattr(self, f)) for f in fields)

aiohttp_devtools/runserver/serve.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
import sys
66
from errno import EADDRINUSE
77
from pathlib import Path
8-
from typing import Any, Iterator, Optional
8+
from typing import Any, Iterator, Optional, NoReturn
99

1010
from aiohttp import WSMsgType, web
1111
from aiohttp.hdrs import LAST_MODIFIED, CONTENT_LENGTH
1212
from aiohttp.typedefs import Handler
1313
from aiohttp.web_exceptions import HTTPNotFound, HTTPNotModified
14+
from aiohttp.web_runner import GracefulExit
1415
from aiohttp.web_urldispatcher import StaticResource
1516
from yarl import URL
1617

@@ -39,7 +40,7 @@ def _change_static_url(app: web.Application, url: str) -> None:
3940
_change_static_url(subapp, url)
4041

4142

42-
def modify_main_app(app: web.Application, config: Config) -> None:
43+
def modify_main_app(app: web.Application, config: Config) -> None: # noqa: C901
4344
"""
4445
Modify the app we're serving to make development easier, eg.
4546
* modify responses to add the livereload snippet
@@ -79,6 +80,18 @@ async def static_middleware(request: web.Request, handler: Handler) -> web.Strea
7980

8081
app.middlewares.insert(0, static_middleware)
8182

83+
# Fallback option to shutdown the application if signals don't work (e.g. Windows).
84+
if config.shutdown_by_url:
85+
async def do_shutdown(request: web.Request) -> web.Response:
86+
def shutdown() -> NoReturn:
87+
raise GracefulExit()
88+
asyncio.get_running_loop().call_soon(shutdown)
89+
return web.Response()
90+
91+
path = config.path_prefix + "/shutdown"
92+
app.router.add_route("GET", path, do_shutdown, name="_devtools.shutdown")
93+
dft_logger.debug("Created shutdown endpoint at http://{}:{}{}".format(config.host, config.main_port, path))
94+
8295
if config.static_path is not None:
8396
static_url = 'http://{}:{}/{}'.format(config.host, config.aux_port, static_path)
8497
dft_logger.debug('settings app static_root_url to "%s"', static_url)

aiohttp_devtools/runserver/watch.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from multiprocessing import Process
66
from pathlib import Path
77
from typing import AsyncIterator, Iterable, Optional, Tuple, Union
8+
from contextlib import suppress
89

910
from aiohttp import ClientSession, web
11+
from aiohttp.client_exceptions import ClientError, ClientConnectionError
1012
from watchfiles import awatch
1113

1214
from ..exceptions import AiohttpDevException
@@ -71,7 +73,7 @@ def is_static(changes: Iterable[Tuple[object, str]]) -> bool:
7173
self._reloads += 1
7274
if any(f.endswith('.py') for _, f in changes):
7375
logger.debug('%d changes, restarting server', len(changes))
74-
self._stop_dev_server()
76+
await self._stop_dev_server()
7577
self._start_dev_server()
7678
await self._src_reload_when_live(live_checks)
7779
elif len(changes) == 1 and is_static(changes):
@@ -119,10 +121,34 @@ def _start_dev_server(self) -> None:
119121
self._process = Process(target=serve_main_app, args=(self._config, tty_path))
120122
self._process.start()
121123

122-
def _stop_dev_server(self) -> None:
124+
async def _stop_dev_server(self) -> None:
123125
if self._process.is_alive():
124126
logger.debug('stopping server process...')
127+
if self._config.shutdown_by_url: # Workaround for signals not working on Windows
128+
url = "http://localhost:{}{}/shutdown".format(self._config.main_port, self._config.path_prefix)
129+
logger.debug("Attempting to stop process via shutdown endpoint {}".format(url))
130+
try:
131+
with suppress(ClientConnectionError):
132+
async with ClientSession() as session:
133+
async with session.get(url):
134+
pass
135+
except (ConnectionError, ClientError, asyncio.TimeoutError) as ex:
136+
if self._process.is_alive():
137+
msg = "shutdown endpoint caused an error (will try signals next)"
138+
logger.warning(msg.format(type(ex), ex), exc_info=True)
139+
else:
140+
msg = "process stopped (despite error at shutdown endpoint)"
141+
logger.warning(msg.format(type(ex), ex), exc_info=True)
142+
return
143+
else:
144+
self._process.join(5)
145+
if self._process.exitcode is None:
146+
logger.warning("shutdown endpoint did not terminate process, trying signals")
147+
else:
148+
logger.debug("process stopped via shutdown endpoint")
149+
return
125150
if self._process.pid:
151+
logger.debug("sending SIGINT")
126152
os.kill(self._process.pid, signal.SIGINT)
127153
self._process.join(5)
128154
if self._process.exitcode is None:
@@ -136,7 +162,7 @@ def _stop_dev_server(self) -> None:
136162

137163
async def close(self, *args: object) -> None:
138164
self.stopper.set()
139-
self._stop_dev_server()
165+
await self._stop_dev_server()
140166
if self._session is None:
141167
raise RuntimeError("Object not started correctly before calling .close()")
142168
await asyncio.gather(super().close(), self._session.close())

tests/cleanup_app.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Test script for checking if cleanup/shutdown handlers are called.
3+
Used in test_runserver_cleanup.py.
4+
5+
This test has proved to be quite difficult to automate in Windows
6+
(see discussion at https://github.com/aio-libs/aiohttp-devtools/pull/549)
7+
so it must be done manually as per the protocol below.
8+
9+
Test Protocol:
10+
11+
1. Run a Windows console such as Git Bash; ensure a working Python version is available
12+
2. ``cd`` to the main aiohttp-devtools directory (root of the git repo)
13+
3. Run ``python -c "from aiohttp_devtools.cli import runserver; runserver()" -v tests/cleanup_app.py``
14+
4. The console output should show the "CTX BEFORE" and "STARTUP" output from the code below
15+
5. Edit this file, e.g. a simple whitespace change, and save
16+
6. The console output should show the "SHUTDOWN" and "CTX AFTER" output, followed by the startup output
17+
7. End the process in the console with Ctrl-C
18+
8. The console should again show the shutdown output
19+
"""
20+
from aiohttp import web
21+
22+
23+
async def hello(_request):
24+
return web.Response(text="hello, world")
25+
26+
27+
async def startup(_app):
28+
print("====> STARTUP")
29+
30+
31+
async def context(_app):
32+
print("====> CTX BEFORE")
33+
yield
34+
print("====> CTX AFTER")
35+
36+
37+
async def shutdown(_app):
38+
print("====> SHUTDOWN")
39+
40+
41+
app = web.Application()
42+
app.router.add_get("/", hello)
43+
app.on_startup.append(startup)
44+
app.cleanup_ctx.append(context)
45+
app.on_shutdown.append(shutdown)

tests/test_runserver_cleanup.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import asyncio
2+
import sys
3+
from pathlib import Path
4+
5+
from aiohttp import ClientSession, ClientTimeout
6+
7+
from .conftest import forked
8+
9+
10+
@forked # forked doesn't run on Windows and is skipped - see cleanup_app.py instead
11+
async def test_server_cleanup_by_url() -> None:
12+
async def is_server_running() -> bool:
13+
async with ClientSession(timeout=ClientTimeout(total=1)) as session:
14+
for i in range(30):
15+
try:
16+
async with session.get("http://localhost:8000/") as r:
17+
assert r.status == 200
18+
text = await r.text()
19+
assert "hello, world" in text
20+
return True
21+
except OSError:
22+
await asyncio.sleep(0.5)
23+
except asyncio.TimeoutError:
24+
pass
25+
return False
26+
27+
proc = await asyncio.create_subprocess_exec(
28+
sys.executable, "-c", "from aiohttp_devtools.cli import runserver; runserver()", # same as `adev runserver`
29+
str(Path(__file__).parent / "cleanup_app.py"), "--shutdown-by-url", "-v", # TODO(PY38): Remove str()
30+
stdout=asyncio.subprocess.PIPE,
31+
stderr=asyncio.subprocess.PIPE,
32+
)
33+
try:
34+
assert await is_server_running()
35+
finally:
36+
proc.terminate()
37+
38+
await asyncio.sleep(2)
39+
stdout, stderr = await proc.communicate()
40+
assert b"process stopped via shutdown endpoint" in stderr
41+
lines = tuple(x[6:] for x in stdout.decode('UTF-8').splitlines() if x.startswith('====> '))
42+
assert lines == ("CTX BEFORE", "STARTUP", "SHUTDOWN", "CTX AFTER")

tests/test_runserver_main.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import asyncio
22
import json
3-
import os
4-
import signal
5-
import time
63
from unittest import mock
74

85
import aiohttp
@@ -115,11 +112,6 @@ async def hello(request):
115112
assert len(aux_app.cleanup_ctx) == 1
116113

117114

118-
def kill_parent_soon(pid):
119-
time.sleep(0.2)
120-
os.kill(pid, signal.SIGINT)
121-
122-
123115
@forked
124116
async def test_run_app_aiohttp_client(tmpworkdir, aiohttp_client):
125117
mktree(tmpworkdir, SIMPLE_APP)

tests/test_runserver_serve.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import json
22
import pathlib
33
import socket
4-
from platform import system as get_os_family
54
from typing import Dict
65
from unittest.mock import MagicMock
76

@@ -16,18 +15,12 @@
1615

1716
from .conftest import SIMPLE_APP, create_future
1817

19-
non_windows_test = pytest.mark.skipif(
20-
get_os_family() == 'Windows',
21-
reason='This only works under UNIX-based OS and gets stuck under Windows',
22-
)
23-
2418

2519
async def test_check_port_open(unused_tcp_port_factory):
2620
port = unused_tcp_port_factory()
2721
await check_port_open(port, 0.001)
2822

2923

30-
@non_windows_test # FIXME: probably needs some sock options
3124
async def test_check_port_not_open(unused_tcp_port_factory):
3225
port = unused_tcp_port_factory()
3326
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:

tests/test_runserver_watch.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import sys
12
import asyncio
23
from functools import partial
3-
from platform import system as get_os_family
44
from unittest.mock import MagicMock, call
55

66
import pytest
@@ -11,9 +11,10 @@
1111

1212
from .conftest import create_future
1313

14-
non_windows_test = pytest.mark.skipif(
15-
get_os_family() == 'Windows',
16-
reason='This only works under UNIX-based OS and gets stuck under Windows',
14+
15+
needs_py38_test = pytest.mark.skipif(
16+
sys.version_info < (3, 8),
17+
reason="This only works on Python >=3.8 because otherwise MagicMock can't be used in 'await' expression",
1718
)
1819

1920

@@ -75,7 +76,7 @@ async def test_multiple_file_change(event_loop, mocker):
7576
await app_task._session.close()
7677

7778

78-
@non_windows_test
79+
@needs_py38_test
7980
async def test_python_no_server(event_loop, mocker):
8081
mocked_awatch = mocker.patch('aiohttp_devtools.runserver.watch.awatch')
8182
mocked_awatch.side_effect = create_awatch_mock({('x', '/path/to/file.py')})
@@ -159,20 +160,20 @@ def join(self, wait):
159160
pass
160161

161162

162-
def test_stop_process_dead(smart_caplog, mocker):
163+
async def test_stop_process_dead(smart_caplog, mocker):
163164
mock_kill = mocker.patch('aiohttp_devtools.runserver.watch.os.kill')
164165
mocker.patch('aiohttp_devtools.runserver.watch.awatch')
165166
mocker.patch('asyncio.Event')
166167
app_task = AppTask(MagicMock())
167168
app_task._process = MagicMock()
168169
app_task._process.is_alive = MagicMock(return_value=False)
169170
app_task._process.exitcode = 123
170-
app_task._stop_dev_server()
171+
await app_task._stop_dev_server()
171172
assert 'server process already dead, exit code: 123' in smart_caplog
172173
assert mock_kill.called is False
173174

174175

175-
def test_stop_process_clean(mocker):
176+
async def test_stop_process_clean(mocker):
176177
mock_kill = mocker.patch('aiohttp_devtools.runserver.watch.os.kill')
177178
mocker.patch('aiohttp_devtools.runserver.watch.awatch')
178179
mocker.patch('asyncio.Event')
@@ -181,11 +182,10 @@ def test_stop_process_clean(mocker):
181182
app_task._process.is_alive = MagicMock(return_value=True)
182183
app_task._process.pid = 321
183184
app_task._process.exitcode = 123
184-
app_task._stop_dev_server()
185+
await app_task._stop_dev_server()
185186
assert mock_kill.called_once_with(321, 2)
186187

187188

188-
@non_windows_test # There's no signals in Windows
189189
async def test_stop_process_dirty(mocker):
190190
mock_kill = mocker.patch('aiohttp_devtools.runserver.watch.os.kill')
191191
mocker.patch('aiohttp_devtools.runserver.watch.awatch')
@@ -195,6 +195,6 @@ async def test_stop_process_dirty(mocker):
195195
process_mock.is_alive = MagicMock(return_value=True)
196196
process_mock.pid = 321
197197
process_mock.exitcode = None
198-
app_task._stop_dev_server()
198+
await app_task._stop_dev_server()
199199
assert mock_kill.call_args_list == [call(321, 2)]
200200
assert process_mock.kill.called_once()

0 commit comments

Comments
 (0)