pgjones / hypercorn

Hypercorn is an ASGI and WSGI Server based on Hyper libraries and inspired by Gunicorn.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

`InvalidStateError` during termination when running hypercorn programatically through anyio.

jonathanslenders opened this issue · comments

In general, Hypercorn works great when running within anyio. There is however one bug that happens during termination.

Take this code:

from hypercorn.asyncio import serve
from hypercorn.config import Config
from quart import Quart
import anyio
import asyncio

app = Quart(__name__)

@app.route("/ping", methods=["GET"])
async def ping():
    await anyio.sleep(100)
    return "pong"

config = Config()
config.bind = ["localhost:8080"]

async def main():
    async def run_hypercorn():
        await serve(app, config)

    async with anyio.create_task_group() as tg:
        tg.start_soon(run_hypercorn)

        # -- while sleeping here, do
        # -- curl://localhost:8080/ping
        await anyio.sleep(5)

        # Stop hypercorn.
        tg.cancel_scope.cancel()

asyncio.run(main())

So, we run Hypercorn programatically in an anyio Task group. Then we use curl to start an HTTP request which takes a long time to respond, (so it's kept open); and while the HTTP request is open, we terminate the server by cancelling the task group.

(I know, hypercorn has a "shutdown event", but cancellation is something that can always happen when running in an anyio application.)

The error which we get as a result of this results in a long stacktrace holding an exceptiongroup with several CancelledError exception in there. But most important is this:

    | Traceback (most recent call last):
    |   File "test.py", line 22, in run_hypercorn
    |     await serve(app, config)
    |   File ".../python3.9/site-packages/hypercorn/asyncio/__init__.py", line 44, in serve
    |     await worker_serve(
    |   File ".../python3.9/site-packages/hypercorn/asyncio/run.py", line 177, in worker_serve
    |     gathered_server_tasks.exception()
    | asyncio.exceptions.InvalidStateError: Exception is not set.

So, it looks like no exception was set in gathered_server_tasks. I'm not totally sure what is the right solution.

I'd love to see if hypercorn could use anyio task groups internally instead of reinventing a TaskGroup which doesn't support proper cancellation. (I'm willing to contribute a PR, if that helps.)

For anyone coming across this issue. We are using the following workaround to serve Hypercorn within an anyio application, to prevent the above crash:

from anyio import Event, move_on_after, sleep_forever
from hypercorn.asyncio import serve
from hypercorn.config import Config
from hypercorn.typing import ASGIFramework

async def run_hypercorn_server(app: ASGIFramework, config: Config) -> None:
    """
    Run ASGI application using Hypercorn.
    This wraps Hypercorn's `serve()` function, but allows for the server to be
    stopped through cancellation.

    :param app: Any ASGI application. For instance, a FastAPI or Starlette
        application.
    """
    async def run() -> None:
        await serve(
            app,
            config,
            # Pass a dummy shutdown trigger. We cancel through
            # cancellation. We still have to pass a `shutdown_trigger`
            # in order to prevent Hypercorn from installing its own
            # signal handlers.
            shutdown_trigger=Event().wait,
        )

    # Start the hypercorn web server.
    webserver_task = asyncio.create_task(run())
    try:
        # wait until cancelled or task finishes.
        await sleep_forever()
    finally:
        # Terminate the web server by cancelling the asyncio task.
        with move_on_after(1, shield=True):
            # Cancel webserver task, and wait for cancellation to
            # complete.
            webserver_task.cancel()
            try:
                await webserver_task
            except asyncio.CancelledError:
                pass

Of course, it would be better if Hypercorn would not crash with an InvalidStateError when a CancelledError is thrown repeatedly at every await-checkpoint because of anyio.