sanic-org / sanic

Accelerate your web app development | Build fast. Run fast.

Home Page:https://sanic.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

multi-server broken in 22.12

militantwalrus opened this issue · comments

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

If running a simple sanic server script with multi-serve under sanic==22.12.0, the "second" port configured is always "broken".

Works fine under sanic==22.9.0 (pythons 3.8.5, 3.11.0)

 An OSError was detected on startup. The encountered error was: [Errno 98] error while attempting to bind on address ('127.0.0.1', 9000): address already in use
Traceback (most recent call last):
  File "/opt/python/3.8.5/lib/python3.8/site-packages/sanic/mixins/startup.py", line 1083, in _start_servers
    server_info.server = await serve(**serve_args)
  File "/opt/python/3.8.5/lib/python3.8/site-packages/sanic/server/async_server.py", line 121, in __await__
    self.server = task.result()
  File "uvloop/loop.pyx", line 1790, in create_server
OSError: [Errno 98] error while attempting to bind on address ('127.0.0.1', 9000): address already in use

Code snippet

#!/opt/python/3.8.5/bin/python3

from sanic import Sanic
from sanic.response import text

app = Sanic("main")
ctl = Sanic("control")

app.prepare(port=8080)
ctl.prepare(port=9000)


@app.get("/")
async def index(request):
    return text("index")


@ctl.get("/control")
async def ctl(request):
    return text("control")


if __name__ == "__main__":
    Sanic.serve()

Expected Behavior

No response

How do you run Sanic?

As a script (app.run or Sanic.serve)

Operating System

Linux (openSUSE Leap 15.2)

Sanic Version

Sanic v22.12.0 sanic-routing==22.8.0, sanic-ext==22.12.0

Additional context

No response

Your example can be fixed by moving the definition of the applications into the __main__ block.

from sanic import Sanic
from sanic.response import text

app = Sanic("main")
ctl = Sanic("control")


@app.get("/")
async def index(request):
    return text("index")


@ctl.get("/control")
async def control(request):
    return text("control")


if __name__ == "__main__":
    app.prepare(port=8080)
    ctl.prepare(port=9000)
    Sanic.serve()

image

More information

In the context of my work (where the application isn't as simple as the example), I tried arranging the code to follow the order of your "fixed" example, where .prepare() comes after both the calls to Sanic(name) and the definition and attach of the routes. This however, got me errors which suggested the use of AppLoader.

sanic.exceptions.SanicException: Sanic app name 'control' not found.
App instantiation must occur outside if __name__ == '__main__' block or by using an AppLoader.
See https://sanic.dev/en/guide/deployment/app-loader.html for more details.

I assume that the outside if __name__ in the error message should actually be of, not if.

My __main__ block in the real app looks more like:

if __name__ == "__main__":
     AppBuilder.build()   # parses a config file, gets all relevant app names and ports from config
     Sanic.serve()

All calls to some_app = Sanic("some_name) [there are 2 such] and all calls to .prepare() have happpened inside of AppBuilder.build() and so have executed prior to the call to Sanic.serve(), but within the __name__ == "__main__" block.

Can you give any further clarification on what needs to happen in such a situation?

If the answer is AppLoader: https://sanic.dev/en/guide/deployment/app-loader.html
(I tried a "straightforward" conversion, which turned out to be not-so-straightforward),

Could you give some rules of the road for a multi-app, multi-port application?
The example for AppLoader seems to support a single Sanic app, but how to use that pattern to set up multiple apps (on different ports) like my simplified example?

Thank you very much.

@militantwalrus How about something like this:

from typing import Any
from sanic import Sanic
from sanic.response import text


class EarlyPrepareSanic(Sanic):
    def early_prepare(self, **kwargs: Any) -> None:
        self.ctx.prepare_kwargs = kwargs


class AppContainer:
    def __init__(self, *apps: EarlyPrepareSanic) -> None:
        self.apps = apps

    def prepare(self) -> None:
        for app in self.apps:
            app.prepare(**app.ctx.prepare_kwargs)


class AppBuilder:
    @classmethod
    def build(cls) -> AppContainer:
        app = EarlyPrepareSanic("main")
        ctl = EarlyPrepareSanic("control")

        @app.get("/")
        async def index(request):
            return text("index")

        @ctl.get("/control")
        async def control(request):
            return text("control")

        app.early_prepare(port=8080, fast=True)
        ctl.early_prepare(port=9000, fast=True)

        return AppContainer(app, ctl)


app_container = AppBuilder.build()

if __name__ == "__main__":
    app_container.prepare()
    Sanic.serve()

Without knowing your architecture, I am taking some guesses here. It does not seem like the AppBuilder.build class method returned anything, so I created a container object to hold the application instances. I am sure you can tweak this to fit your needs.

The goal is to:

  1. Get the calls to Sanic(...) to happen in the global scope so that they are registered. This avoids the complexity of having to deal with the AppLoader yourself. If you would like to tuck AppBuilder.build back into the __main__ block, then LMK and I can help you with that solution.
  2. Since prepare needs to be only in the main process, we will just tuck away the arguments until they are needed. This allows the server definition to be done inside the AppBuilder.build, which is no longer only in the main process. TBH, I kind of like this sort of lazy pattern and maybe it is worth adding something similar to early_prepare for these sorts of cases.

LMK your thoughts. Happy to help you iterate on this. Might be easier to go back and forth if you ping me on Discord though.

You're fairly close to what I'm doing; the principle difference is that the addition of the route methods, and the .prepare() are happening in AppContainer.__init__() - the ordering of events is otherwise the same, however.

But yes, we'd want AppBuilder.build() in that __main__. I'm afraid I don't quite see how the (global) scoping thing is meant to work (what the expectations and rules are). startup.py and the _app_registry? Haven't groked the full path yet.

Aha, found the discord channel.

Thank you!