Repository: sanic-org/sanic

Issue:
Uvicorn ignores listeners errors
**Describe the bug**
When an exception happens on a listener the error is not reported and the server doesn't crash when using Uvicorn.


**Code snippet**
```python
from sanic import Sanic
from sanic.response import text

app = Sanic("MyHelloWorldApp")

@app.listener("before_server_start")
async def raises(app, loop):
    print("Trying to run faster")
    raise Exception("oh noes")

@app.get("/")
async def hello_world(request):
    return text("Hello, world.")
```

```
~ uvicorn app:app
INFO:     Started server process [49837]
INFO:     Waiting for application startup.
[2022-01-25 14:57:34 +0100] [49837] [INFO]
  ┌────────────────────────────────────────────────────────────────┐
  │                         Sanic v21.12.1                         │
  │                                                                │
  ├───────────────────────┬────────────────────────────────────────┤
  │                       │     mode: production, ASGI             │
  │     ▄███ █████ ██     │   server: sanic                        │
  │    ██                 │   python: 3.9.10                       │
  │     ▀███████ ███▄     │ platform: macOS-12.1-x86_64-i386-64bit │
  │                 ██    │ packages: sanic-routing==0.7.2         │
  │    ████ ████████▀     │                                        │
  │                       │                                        │
  │ Build Fast. Run Fast. │                                        │
  └───────────────────────┴────────────────────────────────────────┘

/Users/andre.ericson/projects/sanic-uvicorn/.venv/lib/python3.9/site-packages/sanic/asgi.py:27: UserWarning: You have set a listener for "before_server_start" in ASGI mode. It will be executed as early as possible, but not before the ASGI server is started.
  warnings.warn(
Trying to run faster
INFO:     ASGI 'lifespan' protocol appears unsupported.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
``` 
**Expected behavior**
The server should crash, or at least log an error.


**Environment (please complete the following information):**
 - MacOS
 - python 3.9.10
 ```
 pip list
Package       Version
------------- ------------
aiofiles      0.8.0
asgiref       3.5.0
click         8.0.3
h11           0.13.0
httptools     0.3.0
multidict     5.2.0
pip           21.3.1
sanic         21.12.1
sanic-routing 0.7.2
setuptools    58.1.0
ujson         5.1.0
uvicorn       0.17.0.post1
uvloop        0.16.0
websockets    10.1
 ```


**Additional context**
Works as expected with `sanic app.app` and also with GunicornWorker.


Discussion:
So when testing this, the exception bubbles up and causes an ASGI lifespan error in both uvicorn and hypercorn.

I'm not sure what the right process is to handle this is here, but I'll look into it.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If this  is incorrect, please respond with an update. Thank you for your contributions.


Resolving patch:
diff --git a/sanic/asgi.py b/sanic/asgi.py
--- a/sanic/asgi.py
+++ b/sanic/asgi.py
@@ -9,7 +9,7 @@
 from sanic.exceptions import ServerError
 from sanic.helpers import Default
 from sanic.http import Stage
-from sanic.log import logger
+from sanic.log import error_logger, logger
 from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
 from sanic.request import Request
 from sanic.response import BaseHTTPResponse
@@ -85,13 +85,27 @@ async def __call__(
     ) -> None:
         message = await receive()
         if message["type"] == "lifespan.startup":
-            await self.startup()
-            await send({"type": "lifespan.startup.complete"})
+            try:
+                await self.startup()
+            except Exception as e:
+                error_logger.exception(e)
+                await send(
+                    {"type": "lifespan.startup.failed", "message": str(e)}
+                )
+            else:
+                await send({"type": "lifespan.startup.complete"})
 
         message = await receive()
         if message["type"] == "lifespan.shutdown":
-            await self.shutdown()
-            await send({"type": "lifespan.shutdown.complete"})
+            try:
+                await self.shutdown()
+            except Exception as e:
+                error_logger.exception(e)
+                await send(
+                    {"type": "lifespan.shutdown.failed", "message": str(e)}
+                )
+            else:
+                await send({"type": "lifespan.shutdown.complete"})
 
 
 class ASGIApp:


Test patch:
diff --git a/tests/test_asgi.py b/tests/test_asgi.py
--- a/tests/test_asgi.py
+++ b/tests/test_asgi.py
@@ -8,7 +8,7 @@
 
 from sanic import Sanic
 from sanic.application.state import Mode
-from sanic.asgi import MockTransport
+from sanic.asgi import ASGIApp, MockTransport
 from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable
 from sanic.request import Request
 from sanic.response import json, text
@@ -16,6 +16,12 @@
 from sanic.signals import RESERVED_NAMESPACES
 
 
+try:
+    from unittest.mock import AsyncMock
+except ImportError:
+    from tests.asyncmock import AsyncMock  # type: ignore
+
+
 @pytest.fixture
 def message_stack():
     return deque()
@@ -558,3 +564,39 @@ def _request(request: Request):
 
     _, response = await app.asgi_client.get("/")
     assert response.text == "http://<ASGI>"
+
+
+@pytest.mark.asyncio
+async def test_error_on_lifespan_exception_start(app, caplog):
+    @app.before_server_start
+    async def before_server_start(_):
+        1 / 0
+
+    recv = AsyncMock(return_value={"type": "lifespan.startup"})
+    send = AsyncMock()
+    app.asgi = True
+
+    with caplog.at_level(logging.ERROR):
+        await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
+
+    send.assert_awaited_once_with(
+        {"type": "lifespan.startup.failed", "message": "division by zero"}
+    )
+
+
+@pytest.mark.asyncio
+async def test_error_on_lifespan_exception_stop(app: Sanic):
+    @app.before_server_stop
+    async def before_server_stop(_):
+        1 / 0
+
+    recv = AsyncMock(return_value={"type": "lifespan.shutdown"})
+    send = AsyncMock()
+    app.asgi = True
+    await app._startup()
+
+    await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
+
+    send.assert_awaited_once_with(
+        {"type": "lifespan.shutdown.failed", "message": "division by zero"}
+    )