diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py index cc8375f0..160b51b5 100644 --- a/sanic/mixins/listeners.py +++ b/sanic/mixins/listeners.py @@ -17,6 +17,8 @@ class ListenerEvent(str, Enum): AFTER_SERVER_STOP = "server.shutdown.after" MAIN_PROCESS_START = auto() MAIN_PROCESS_STOP = auto() + RELOAD_PROCESS_START = auto() + RELOAD_PROCESS_STOP = auto() class ListenerMixin(metaclass=SanicMeta): @@ -73,6 +75,16 @@ class ListenerMixin(metaclass=SanicMeta): ) -> ListenerType[Sanic]: return self.listener(listener, "main_process_stop") + def reload_process_start( + self, listener: ListenerType[Sanic] + ) -> ListenerType[Sanic]: + return self.listener(listener, "reload_process_start") + + def reload_process_stop( + self, listener: ListenerType[Sanic] + ) -> ListenerType[Sanic]: + return self.listener(listener, "reload_process_stop") + def before_server_start( self, listener: ListenerType[Sanic] ) -> ListenerType[Sanic]: diff --git a/sanic/mixins/runner.py b/sanic/mixins/runner.py index 101a5b35..e5e3490d 100644 --- a/sanic/mixins/runner.py +++ b/sanic/mixins/runner.py @@ -11,6 +11,7 @@ from asyncio import ( all_tasks, get_event_loop, get_running_loop, + new_event_loop, ) from contextlib import suppress from functools import partial @@ -32,6 +33,7 @@ from sanic.models.handler_types import ListenerType from sanic.server import Signal as ServerSignal from sanic.server import try_use_uvloop from sanic.server.async_server import AsyncioServer +from sanic.server.events import trigger_events from sanic.server.protocols.http_protocol import HttpProtocol from sanic.server.protocols.websocket_protocol import WebSocketProtocol from sanic.server.runners import serve, serve_multiple, serve_single @@ -538,15 +540,21 @@ class RunnerMixin(metaclass=SanicMeta): except IndexError: raise RuntimeError("Did not find any applications.") + reloader_start = primary.listeners.get("reload_process_start") + reloader_stop = primary.listeners.get("reload_process_stop") # We want to run auto_reload if ANY of the applications have it enabled if ( cls.should_auto_reload() and os.environ.get("SANIC_SERVER_RUNNING") != "true" - ): + ): # no cov + loop = new_event_loop() + trigger_events(reloader_start, loop) reload_dirs: Set[Path] = primary.state.reload_dirs.union( *(app.state.reload_dirs for app in apps) ) - return reloader_helpers.watchdog(1.0, reload_dirs) + reloader_helpers.watchdog(1.0, reload_dirs) + trigger_events(reloader_stop, loop) + return # This exists primarily for unit testing if not primary.state.server_info: # no cov diff --git a/tests/test_reloader.py b/tests/test_reloader.py index d78b49a9..ad8c5665 100644 --- a/tests/test_reloader.py +++ b/tests/test_reloader.py @@ -58,6 +58,36 @@ def write_app(filename, **runargs): return text +def write_listener_app(filename, **runargs): + start_text = secrets.token_urlsafe() + stop_text = secrets.token_urlsafe() + with open(filename, "w") as f: + f.write( + dedent( + f"""\ + import os + from sanic import Sanic + + app = Sanic(__name__) + + app.route("/")(lambda x: x) + + @app.reload_process_start + async def reload_start(*_): + print("reload_start", os.getpid(), {start_text!r}) + + @app.reload_process_stop + async def reload_stop(*_): + print("reload_stop", os.getpid(), {stop_text!r}) + + if __name__ == "__main__": + app.run(**{runargs!r}) + """ + ) + ) + return start_text, stop_text + + def write_json_config_app(filename, jsonfile, **runargs): with open(filename, "w") as f: f.write( @@ -92,10 +122,10 @@ def write_file(filename): return text -def scanner(proc): +def scanner(proc, trigger="complete"): for line in proc.stdout: line = line.decode().strip() - if line.startswith("complete"): + if line.startswith(trigger): yield line @@ -108,7 +138,7 @@ argv = dict( "sanic", "--port", "42204", - "--debug", + "--auto-reload", "reloader.app", ], ) @@ -118,7 +148,7 @@ argv = dict( "runargs, mode", [ (dict(port=42202, auto_reload=True), "script"), - (dict(port=42203, debug=True), "module"), + (dict(port=42203, auto_reload=True), "module"), ({}, "sanic"), ], ) @@ -151,7 +181,7 @@ async def test_reloader_live(runargs, mode): "runargs, mode", [ (dict(port=42302, auto_reload=True), "script"), - (dict(port=42303, debug=True), "module"), + (dict(port=42303, auto_reload=True), "module"), ({}, "sanic"), ], ) @@ -183,3 +213,30 @@ async def test_reloader_live_with_dir(runargs, mode): terminate(proc) with suppress(TimeoutExpired): proc.wait(timeout=3) + + +def test_reload_listeners(): + with TemporaryDirectory() as tmpdir: + filename = os.path.join(tmpdir, "reloader.py") + start_text, stop_text = write_listener_app( + filename, port=42305, auto_reload=True + ) + + proc = Popen( + argv["script"], cwd=tmpdir, stdout=PIPE, creationflags=flags + ) + try: + timeout = Timer(TIMER_DELAY, terminate, [proc]) + timeout.start() + # Python apparently keeps using the old source sometimes if + # we don't sleep before rewrite (pycache timestamp problem?) + sleep(1) + line = scanner(proc, "reload_start") + assert start_text in next(line) + line = scanner(proc, "reload_stop") + assert stop_text in next(line) + finally: + timeout.cancel() + terminate(proc) + with suppress(TimeoutExpired): + proc.wait(timeout=3) diff --git a/tests/test_server_events.py b/tests/test_server_events.py index cd3f5266..712b3c68 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -199,3 +199,16 @@ async def test_missing_startup_raises_exception(app): with pytest.raises(SanicException): await srv.before_start() + + +def test_reload_listeners_attached(app): + async def dummy(*_): + ... + + app.reload_process_start(dummy) + app.reload_process_stop(dummy) + app.listener("reload_process_start")(dummy) + app.listener("reload_process_stop")(dummy) + + assert len(app.listeners.get("reload_process_start")) == 2 + assert len(app.listeners.get("reload_process_stop")) == 2