Repository: sanic-org/sanic
Pull request #2328 (2021-12-02 21:11:39)

Add runtime checking to create_server to verify that startup has been run
When using `create_server`, you need to manually call some functions. One of them being `app.startup`. If you do not, then the router is never finalized and you get a cryptic message, but this only happens on the first request. That means the server seemingly starts fine even though it is in a bad state.

```
  File "/home/user/project/.venv/lib/python3.9/site-packages/sanic/signals.py", line 73, in get
    group, param_basket = self.find_route(
TypeError: 'NoneType' object is not callable
Task exception was never retrieved
future: <Task finished name='Task-3' coro=<HttpProtocol.connection_task() done, defined at /home/user/project/.venv/lib/python3.9/site-packages/sanic/server/protocols/http_protocol.py:92> exception=TypeError("'NoneType' object is not callable")>
Traceback (most recent call last):
```

This PR simple adds a new flag to the application state to indicate whether this function has been run.


Patch:
diff --git a/examples/run_async.py b/examples/run_async.py
index a30417d7..64f6842d 100644
--- a/examples/run_async.py
+++ b/examples/run_async.py
@@ -1,7 +1,5 @@
 import asyncio
 
-from signal import SIGINT, signal
-
 import uvloop
 
 from sanic import Sanic, response
@@ -15,17 +13,18 @@ async def test(request):
     return response.json({"answer": "42"})
 
 
-asyncio.set_event_loop(uvloop.new_event_loop())
-server = app.create_server(
-    host="0.0.0.0", port=8000, return_asyncio_server=True
-)
-loop = asyncio.get_event_loop()
-task = asyncio.ensure_future(server)
-server = loop.run_until_complete(task)
-loop.run_until_complete(server.startup())
-signal(SIGINT, lambda s, f: loop.stop())
-
-try:
-    loop.run_forever()
-finally:
-    loop.stop()
+async def main():
+    server = await app.create_server(
+        port=8000, host="0.0.0.0", return_asyncio_server=True
+    )
+
+    if server is None:
+        return
+
+    await server.startup()
+    await server.serve_forever()
+
+
+if __name__ == "__main__":
+    asyncio.set_event_loop(uvloop.new_event_loop())
+    asyncio.run(main())
diff --git a/sanic/app.py b/sanic/app.py
index f0230165..6a287471 100644
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -1703,6 +1703,7 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
             self.error_handler, fallback=self.config.FALLBACK_ERROR_FORMAT
         )
         TouchUp.run(self)
+        self.state.is_started = True
 
     async def _server_event(
         self,
diff --git a/sanic/application/state.py b/sanic/application/state.py
index eb180708..f5ff4fe4 100644
--- a/sanic/application/state.py
+++ b/sanic/application/state.py
@@ -42,6 +42,7 @@ class ApplicationState:
     reload_dirs: Set[Path] = field(default_factory=set)
     server: Server = field(default=Server.SANIC)
     is_running: bool = field(default=False)
+    is_started: bool = field(default=False)
     is_stopping: bool = field(default=False)
     verbosity: int = field(default=0)
     workers: int = field(default=0)
diff --git a/sanic/server/async_server.py b/sanic/server/async_server.py
index 33b8b4c0..1ce5688d 100644
--- a/sanic/server/async_server.py
+++ b/sanic/server/async_server.py
@@ -2,20 +2,27 @@ from __future__ import annotations
 
 import asyncio
 
+from typing import TYPE_CHECKING
+from warnings import warn
+
 from sanic.exceptions import SanicException
 
 
+if TYPE_CHECKING:
+    from sanic import Sanic
+
+
 class AsyncioServer:
     """
     Wraps an asyncio server with functionality that might be useful to
     a user who needs to manage the server lifecycle manually.
     """
 
-    __slots__ = ("app", "connections", "loop", "serve_coro", "server", "init")
+    __slots__ = ("app", "connections", "loop", "serve_coro", "server")
 
     def __init__(
         self,
-        app,
+        app: Sanic,
         loop,
         serve_coro,
         connections,
@@ -27,13 +34,20 @@ class AsyncioServer:
         self.loop = loop
         self.serve_coro = serve_coro
         self.server = None
-        self.init = False
+
+    @property
+    def init(self):
+        warn(
+            "AsyncioServer.init has been deprecated and will be removed "
+            "in v22.6. Use Sanic.state.is_started instead.",
+            DeprecationWarning,
+        )
+        return self.app.state.is_started
 
     def startup(self):
         """
         Trigger "before_server_start" events
         """
-        self.init = True
         return self.app._startup()
 
     def before_start(self):
@@ -77,30 +91,33 @@ class AsyncioServer:
             return task
 
     def start_serving(self):
-        if self.server:
-            try:
-                return self.server.start_serving()
-            except AttributeError:
-                raise NotImplementedError(
-                    "server.start_serving not available in this version "
-                    "of asyncio or uvloop."
-                )
+        return self._serve(self.server.start_serving)
 
     def serve_forever(self):
+        return self._serve(self.server.serve_forever)
+
+    def _serve(self, serve_func):
         if self.server:
+            if not self.app.state.is_started:
+                raise SanicException(
+                    "Cannot run Sanic server without first running "
+                    "await server.startup()"
+                )
+
             try:
-                return self.server.serve_forever()
+                return serve_func()
             except AttributeError:
+                name = serve_func.__name__
                 raise NotImplementedError(
-                    "server.serve_forever not available in this version "
+                    f"server.{name} not available in this version "
                     "of asyncio or uvloop."
                 )
 
     def _server_event(self, concern: str, action: str):
-        if not self.init:
+        if not self.app.state.is_started:
             raise SanicException(
                 "Cannot dispatch server event without "
-                "first running server.startup()"
+                "first running await server.startup()"
             )
         return self.app._server_event(concern, action, loop=self.loop)
 


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index 75a5b65f..9bcc87e7 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2,12 +2,10 @@ import asyncio
 import logging
 import re
 
-from email import message
 from inspect import isawaitable
 from os import environ
 from unittest.mock import Mock, patch
 
-import py
 import pytest
 
 from sanic import Sanic
@@ -41,41 +39,39 @@ def test_app_loop_running(app):
 
 
 def test_create_asyncio_server(app):
-    if not uvloop_installed():
-        loop = asyncio.get_event_loop()
-        asyncio_srv_coro = app.create_server(return_asyncio_server=True)
-        assert isawaitable(asyncio_srv_coro)
-        srv = loop.run_until_complete(asyncio_srv_coro)
-        assert srv.is_serving() is True
+    loop = asyncio.get_event_loop()
+    asyncio_srv_coro = app.create_server(return_asyncio_server=True)
+    assert isawaitable(asyncio_srv_coro)
+    srv = loop.run_until_complete(asyncio_srv_coro)
+    assert srv.is_serving() is True
 
 
 def test_asyncio_server_no_start_serving(app):
-    if not uvloop_installed():
-        loop = asyncio.get_event_loop()
-        asyncio_srv_coro = app.create_server(
-            port=43123,
-            return_asyncio_server=True,
-            asyncio_server_kwargs=dict(start_serving=False),
-        )
-        srv = loop.run_until_complete(asyncio_srv_coro)
-        assert srv.is_serving() is False
+    loop = asyncio.get_event_loop()
+    asyncio_srv_coro = app.create_server(
+        port=43123,
+        return_asyncio_server=True,
+        asyncio_server_kwargs=dict(start_serving=False),
+    )
+    srv = loop.run_until_complete(asyncio_srv_coro)
+    assert srv.is_serving() is False
 
 
 def test_asyncio_server_start_serving(app):
-    if not uvloop_installed():
-        loop = asyncio.get_event_loop()
-        asyncio_srv_coro = app.create_server(
-            port=43124,
-            return_asyncio_server=True,
-            asyncio_server_kwargs=dict(start_serving=False),
-        )
-        srv = loop.run_until_complete(asyncio_srv_coro)
-        assert srv.is_serving() is False
-        loop.run_until_complete(srv.start_serving())
-        assert srv.is_serving() is True
-        wait_close = srv.close()
-        loop.run_until_complete(wait_close)
-        # Looks like we can't easily test `serve_forever()`
+    loop = asyncio.get_event_loop()
+    asyncio_srv_coro = app.create_server(
+        port=43124,
+        return_asyncio_server=True,
+        asyncio_server_kwargs=dict(start_serving=False),
+    )
+    srv = loop.run_until_complete(asyncio_srv_coro)
+    assert srv.is_serving() is False
+    loop.run_until_complete(srv.startup())
+    loop.run_until_complete(srv.start_serving())
+    assert srv.is_serving() is True
+    wait_close = srv.close()
+    loop.run_until_complete(wait_close)
+    # Looks like we can't easily test `serve_forever()`
 
 
 def test_create_server_main(app, caplog):
@@ -92,6 +88,21 @@ def test_create_server_main(app, caplog):
     ) in caplog.record_tuples
 
 
+def test_create_server_no_startup(app):
+    loop = asyncio.get_event_loop()
+    asyncio_srv_coro = app.create_server(
+        port=43124,
+        return_asyncio_server=True,
+        asyncio_server_kwargs=dict(start_serving=False),
+    )
+    srv = loop.run_until_complete(asyncio_srv_coro)
+    message = (
+        "Cannot run Sanic server without first running await server.startup()"
+    )
+    with pytest.raises(SanicException, match=message):
+        loop.run_until_complete(srv.start_serving())
+
+
 def test_create_server_main_convenience(app, caplog):
     app.main_process_start(lambda *_: ...)
     loop = asyncio.get_event_loop()
@@ -106,6 +117,19 @@ def test_create_server_main_convenience(app, caplog):
     ) in caplog.record_tuples
 
 
+def test_create_server_init(app, caplog):
+    loop = asyncio.get_event_loop()
+    asyncio_srv_coro = app.create_server(return_asyncio_server=True)
+    server = loop.run_until_complete(asyncio_srv_coro)
+
+    message = (
+        "AsyncioServer.init has been deprecated and will be removed in v22.6. "
+        "Use Sanic.state.is_started instead."
+    )
+    with pytest.warns(DeprecationWarning, match=message):
+        server.init
+
+
 def test_app_loop_not_running(app):
     with pytest.raises(SanicException) as excinfo:
         app.loop