From fa811a667864d5a02e36de7063f7be02955ceb64 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 18 Sep 2025 15:56:03 +0200 Subject: [PATCH 1/4] WIP: add lifespan support (monkay based) Changes: - add lifespan support via monkay.asgi - Bump minimum python version (near EOL, needs discussion) --- daphne/cli.py | 7 +++++++ daphne/management/commands/runserver.py | 8 ++++++++ daphne/server.py | 22 +++++++++++++++++----- pyproject.toml | 5 ++--- tests/test_cli.py | 6 ++++++ tox.ini | 2 +- 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/daphne/cli.py b/daphne/cli.py index a5623ad..9dac979 100755 --- a/daphne/cli.py +++ b/daphne/cli.py @@ -160,6 +160,12 @@ class CommandLineInterface: self.parser.add_argument( "--no-server-name", dest="server_name", action="store_const", const="" ) + self.parser.add_argument( + "--enable-lifespan", + dest="enable_lifespan", + action="store_true", + help="Enables lifespan support.", + ) self.server = None @@ -279,6 +285,7 @@ class CommandLineInterface: action_logger=( AccessLogGenerator(access_log_stream) if access_log_stream else None ), + enable_lifespan=args.enable_lifespan, root_path=args.root_path, verbosity=args.verbosity, proxy_forwarded_address_header=self._get_forwarded_host(args=args), diff --git a/daphne/management/commands/runserver.py b/daphne/management/commands/runserver.py index d505f33..1b6b186 100644 --- a/daphne/management/commands/runserver.py +++ b/daphne/management/commands/runserver.py @@ -51,6 +51,13 @@ class Command(RunserverCommand): default=True, help="Run the old WSGI-based runserver rather than the ASGI-based one", ) + parser.add_argument( + "--enable-lifespan", + action="store_true", + dest="enable_lifespan", + default=False, + help="Enable lifespan support.", + ) parser.add_argument( "--http_timeout", action="store", @@ -141,6 +148,7 @@ class Command(RunserverCommand): application=self.get_application(options), endpoints=endpoints, signal_handlers=not options["use_reloader"], + enable_lifespan=options["enable_lifespan"], action_logger=self.log_action, http_timeout=self.http_timeout, root_path=getattr(settings, "FORCE_SCRIPT_NAME", "") or "", diff --git a/daphne/server.py b/daphne/server.py index a6d3819..2c05179 100755 --- a/daphne/server.py +++ b/daphne/server.py @@ -5,6 +5,7 @@ import sys # isort:skip import warnings # isort:skip from concurrent.futures import ThreadPoolExecutor # isort:skip from twisted.internet import asyncioreactor # isort:skip +from monkay.asgi import Lifespan # isort:skip twisted_loop = asyncio.new_event_loop() @@ -66,6 +67,7 @@ class Server: application_close_timeout=10, ready_callable=None, server_name="daphne", + enable_lifespan=False, ): self.application = application self.endpoints = endpoints or [] @@ -93,6 +95,9 @@ class Server: if not self.endpoints: logger.error("No endpoints. This server will not listen on anything.") sys.exit(1) + self.lifespan_context = None + if enable_lifespan: + self.lifespan_context = Lifespan(self.application) def run(self): # A dict of protocol: {"application_instance":, "connected":, "disconnected":} dicts @@ -120,6 +125,12 @@ class Server: logger.info( "HTTP/2 support not enabled (install the http2 and tls Twisted extras)" ) + # Set the asyncio reactor's event loop as global + # TODO: Should we instead pass the global one into the reactor? + evloop = reactor._asyncioEventloop + asyncio.set_event_loop(evloop) + if self.lifespan_context is not None: + evloop.run_until(self.lifespan_context.__aenter__()) # Kick off the timeout loop reactor.callLater(1, self.application_checker) @@ -133,13 +144,9 @@ class Server: listener.addErrback(self.listen_error) self.listeners.append(listener) - # Set the asyncio reactor's event loop as global - # TODO: Should we instead pass the global one into the reactor? - asyncio.set_event_loop(reactor._asyncioEventloop) - # Verbosity 3 turns on asyncio debug to find those blocking yields if self.verbosity >= 3: - asyncio.get_event_loop().set_debug(True) + evloop.set_debug(True) reactor.addSystemEventTrigger("before", "shutdown", self.kill_all_applications) if not self.abort_start: @@ -323,6 +330,11 @@ class Server: # Make Twisted wait until they're all dead wait_deferred = defer.Deferred.fromFuture(asyncio.gather(*wait_for)) wait_deferred.addErrback(lambda x: None) + # at last execute lifespan cleanup + if self.lifespan_context is not None: + wait_deferred.chainDeferred( + defer.Deferred.fromFuture(self.lifespan_context.__aexit__()) + ) return wait_deferred def timeout_checker(self): diff --git a/pyproject.toml b/pyproject.toml index 9b410aa..11abc5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "daphne" dynamic = ["version"] description = "Django ASGI (HTTP/WebSocket) server" -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ { name = "Django Software Foundation", email = "foundation@djangoproject.com" }, ] @@ -16,7 +16,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -24,7 +23,7 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP", ] -dependencies = ["asgiref>=3.5.2,<4", "autobahn>=22.4.2", "twisted[tls]>=22.4"] +dependencies = ["asgiref>=3.5.2,<4", "autobahn>=22.4.2", "twisted[tls]>=22.4", "monkay>=0.5.0"] [project.optional-dependencies] tests = [ diff --git a/tests/test_cli.py b/tests/test_cli.py index 59f44e7..770ae1f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -253,6 +253,12 @@ class TestCLIInterface(TestCase): self.assertCLI(["--server-name", ""], {"server_name": ""}) self.assertCLI(["--server-name", "python"], {"server_name": "python"}) + def test_enable_lifespan(self): + """ + Passing `--enable-lifespan` will set enable_lifespan. + """ + self.assertCLI(["--enable-lifespan"], {"enable_lifespan": True}) + def test_no_servername(self): """ Passing `--no-server-name` will set server name to '' (empty string) diff --git a/tox.ini b/tox.ini index e3bae91..990781f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{39,310,311,312,313} + py{310,311,312,313} [testenv] extras = tests From b2a34766bc555e2ca248f7dfa465d54994f4bf58 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 22 Sep 2025 12:30:50 +0200 Subject: [PATCH 2/4] fix typo --- daphne/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daphne/server.py b/daphne/server.py index 2c05179..ddf01f4 100755 --- a/daphne/server.py +++ b/daphne/server.py @@ -130,7 +130,7 @@ class Server: evloop = reactor._asyncioEventloop asyncio.set_event_loop(evloop) if self.lifespan_context is not None: - evloop.run_until(self.lifespan_context.__aenter__()) + evloop.run_until_complete(self.lifespan_context.__aenter__()) # Kick off the timeout loop reactor.callLater(1, self.application_checker) From 64f15aa9f035f8eac1733c31543ff687a7e69328 Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 8 Nov 2025 18:45:12 +0100 Subject: [PATCH 3/4] fix cleanup not run, TODO: what is with kill_applications, does the code work? --- daphne/server.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/daphne/server.py b/daphne/server.py index ddf01f4..97fd584 100755 --- a/daphne/server.py +++ b/daphne/server.py @@ -154,7 +154,12 @@ class Server: if self.ready_callable: self.ready_callable() # Run the reactor - reactor.run(installSignalHandlers=self.signal_handlers) + try: + reactor.run(installSignalHandlers=self.signal_handlers) + finally: + # at last execute lifespan cleanup + if self.lifespan_context is not None: + evloop.run_until_complete(self.lifespan_context.__aexit__()) def listen_success(self, port): """ @@ -330,11 +335,6 @@ class Server: # Make Twisted wait until they're all dead wait_deferred = defer.Deferred.fromFuture(asyncio.gather(*wait_for)) wait_deferred.addErrback(lambda x: None) - # at last execute lifespan cleanup - if self.lifespan_context is not None: - wait_deferred.chainDeferred( - defer.Deferred.fromFuture(self.lifespan_context.__aexit__()) - ) return wait_deferred def timeout_checker(self): From 888ada94fd72564e8e6f082fd8acfdf097eb7aae Mon Sep 17 00:00:00 2001 From: alex Date: Sat, 8 Nov 2025 18:49:59 +0100 Subject: [PATCH 4/4] start lifespan later as it is immediately executed --- daphne/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/daphne/server.py b/daphne/server.py index 97fd584..da77314 100755 --- a/daphne/server.py +++ b/daphne/server.py @@ -129,8 +129,6 @@ class Server: # TODO: Should we instead pass the global one into the reactor? evloop = reactor._asyncioEventloop asyncio.set_event_loop(evloop) - if self.lifespan_context is not None: - evloop.run_until_complete(self.lifespan_context.__aenter__()) # Kick off the timeout loop reactor.callLater(1, self.application_checker) @@ -153,11 +151,14 @@ class Server: # Trigger the ready flag if we had one if self.ready_callable: self.ready_callable() + # Run the lifespan setup + if self.lifespan_context is not None: + evloop.run_until_complete(self.lifespan_context.__aenter__()) # Run the reactor try: reactor.run(installSignalHandlers=self.signal_handlers) finally: - # at last execute lifespan cleanup + # Execute lifespan cleanup if self.lifespan_context is not None: evloop.run_until_complete(self.lifespan_context.__aexit__())