`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.