From d253a34cfbdd53335afbc989df22d5c955c148aa Mon Sep 17 00:00:00 2001 From: Hauke D Date: Sun, 23 Apr 2023 14:37:01 +0200 Subject: [PATCH] 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. --- aiohttp_devtools/runserver/config.py | 5 +++++ aiohttp_devtools/runserver/serve.py | 9 ++++++++ aiohttp_devtools/runserver/watch.py | 31 ++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/aiohttp_devtools/runserver/config.py b/aiohttp_devtools/runserver/config.py index 9a131a65..dfabeb37 100644 --- a/aiohttp_devtools/runserver/config.py +++ b/aiohttp_devtools/runserver/config.py @@ -38,6 +38,8 @@ def __init__(self, *, python_path: Optional[str] = None, static_url: str = '/static/', livereload: bool = True, + shutdown_endpoint: bool = sys.platform.startswith("win32"), + path_prefix: str = "/_devtools", app_factory_name: Optional[str] = None, host: str = INFER_HOST, main_port: int = 8000, @@ -66,6 +68,8 @@ def __init__(self, *, self.static_path = self._resolve_path(static_path, "is_dir", "static-path") if static_path else None self.static_url = static_url self.livereload = livereload + self.shutdown_endpoint = shutdown_endpoint + self.path_prefix = path_prefix self.app_factory_name = app_factory_name self.infer_host = host == INFER_HOST self.host = 'localhost' if self.infer_host else host @@ -184,5 +188,6 @@ async def load_app(self, app_factory: AppFactory) -> web.Application: def __str__(self) -> str: fields = ('py_file', 'static_path', 'static_url', 'livereload', + 'shutdown_endpoint', 'path_prefix', 'app_factory_name', 'host', 'main_port', 'aux_port') return 'Config:\n' + '\n'.join(' {0}: {1!r}'.format(f, getattr(self, f)) for f in fields) diff --git a/aiohttp_devtools/runserver/serve.py b/aiohttp_devtools/runserver/serve.py index 1a7c3e79..0b6bb62e 100644 --- a/aiohttp_devtools/runserver/serve.py +++ b/aiohttp_devtools/runserver/serve.py @@ -79,6 +79,15 @@ async def static_middleware(request: web.Request, handler: Handler) -> web.Strea app.middlewares.insert(0, static_middleware) + if config.shutdown_endpoint: # a workaround for singals not working on Windows + from aiohttp.web_runner import GracefulExit + async def get_shutdown(request): + request.app.logger.info('shutting down due to request at endpoint') + raise GracefulExit() + path = config.path_prefix+"/shutdown" + app.router.add_route("GET", path, get_shutdown, name="devtools.shutdown") + dft_logger.debug('set up shutdown endpoint at http://{}:{}{}'.format(config.host, config.main_port, path)) + if config.static_path is not None: static_url = 'http://{}:{}/{}'.format(config.host, config.aux_port, static_path) dft_logger.debug('settings app static_root_url to "%s"', static_url) diff --git a/aiohttp_devtools/runserver/watch.py b/aiohttp_devtools/runserver/watch.py index 8b5079cc..b2d2a121 100644 --- a/aiohttp_devtools/runserver/watch.py +++ b/aiohttp_devtools/runserver/watch.py @@ -5,8 +5,10 @@ from multiprocessing import Process from pathlib import Path from typing import AsyncIterator, Iterable, Optional, Tuple, Union +from contextlib import suppress from aiohttp import ClientSession, web +from aiohttp.client_exceptions import ClientError, ServerDisconnectedError, ClientConnectorError from watchfiles import awatch from ..exceptions import AiohttpDevException @@ -43,7 +45,6 @@ async def cleanup_ctx(self, app: web.Application) -> AsyncIterator[None]: yield await self.close(app) - class AppTask(WatchTask): template_files = '.html', '.jinja', '.jinja2' @@ -71,7 +72,7 @@ def is_static(changes: Iterable[Tuple[object, str]]) -> bool: self._reloads += 1 if any(f.endswith('.py') for _, f in changes): logger.debug('%d changes, restarting server', len(changes)) - self._stop_dev_server() + await self._stop_dev_server() self._start_dev_server() await self._src_reload_when_live(live_checks) elif len(changes) == 1 and is_static(changes): @@ -119,10 +120,32 @@ def _start_dev_server(self) -> None: self._process = Process(target=serve_main_app, args=(self._config, tty_path)) self._process.start() - def _stop_dev_server(self) -> None: + async def _stop_dev_server(self) -> None: if self._process.is_alive(): logger.debug('stopping server process...') + if self._config.shutdown_endpoint: # a workaround for singals not working on Windows + url = 'http://localhost:{}{}/shutdown'.format(self._config.main_port, self._config.path_prefix) + logger.debug('attempting to stop process via shutdown endpoint {}'.format(url)) + try: + # these errors are expected because the request kills the server *immediately* + with suppress(ServerDisconnectedError, ClientConnectorError): + async with self._session.get(url): + pass + except (ConnectionError, ClientError, asyncio.TimeoutError) as ex: + if self._process.is_alive(): + logger.warning("shutdown endpoint caused an error (will try signals next): {}".format(ex)) + else: + logger.warning("process stopped (despite error at shutdown endpoint: {})".format(ex)) + return + else: + self._process.join(5) + if self._process.exitcode is None: + logger.warning('shutdown endpoint did not terminate process, trying signals') + else: + logger.debug('process stopped via shutdown endpoint') + return if self._process.pid: + logger.debug('sending SIGINT') os.kill(self._process.pid, signal.SIGINT) self._process.join(5) if self._process.exitcode is None: @@ -136,7 +159,7 @@ def _stop_dev_server(self) -> None: async def close(self, *args: object) -> None: self.stopper.set() - self._stop_dev_server() + await self._stop_dev_server() if self._session is None: raise RuntimeError("Object not started correctly before calling .close()") await asyncio.gather(super().close(), self._session.close())