Skip to content

Python 3.14 compatibility - TaskGroup, wait_for, and timeout require task context #351

@seidnerj

Description

@seidnerj

Summary

Hypercorn 0.18.0 does not work with Python 3.14. Several asyncio APIs that Hypercorn uses (asyncio.TaskGroup, asyncio.timeout, asyncio.wait_for) now require a proper task context in 3.14, but Hypercorn's asyncio.Runner doesn't establish one.

Environment

  • Hypercorn 0.18.0
  • Python 3.14.4 (Alpine Linux, GCC 15.2.0)
  • Running with --workers 2 --worker-class asyncio

Errors

On startup, Hypercorn crashes with errors like:

RuntimeError: asyncio.TaskGroup requires a running event loop with a current task

and

RuntimeError: timeout() requires a running event loop with a current task

These come from worker_serve() in hypercorn/asyncio/run.py and the lifespan handling in hypercorn/asyncio/lifespan.py.

Root cause

Hypercorn's _run() function uses asyncio.Runner.run() which runs the coroutine directly via loop.run_until_complete(). Python 3.14 now enforces that TaskGroup, timeout(), and wait_for() have a proper asyncio.current_task() context. Since Runner doesn't wrap work in an explicit task the way asyncio.run() does internally, these APIs fail.

Additionally, on Python 3.14 (Alpine/GCC build specifically), asyncio.current_task() returns None in daemon threads even after explicit loop.create_task() + loop.run_until_complete(). This appears to be a CPython bug with the C-level task registry, but it means any code in Hypercorn workers that spawns daemon threads with their own event loops (e.g., background task executors using httpx.AsyncClient) will also break, since anyio and sniffio depend on current_task() being non-None.

Workaround

We are currently monkey-patching three things to run Hypercorn 0.18.0 on Python 3.14:

  1. _run - replaced with a version that wraps work in asyncio.run() instead of using Runner directly, establishing a proper task context
  2. worker_serve - replaced with a version that avoids TaskGroup and asyncio.timeout, using asyncio.wait() and asyncio.ensure_future() instead
  3. asyncio.wait_for - globally replaced with a compatible implementation using asyncio.wait() + asyncio.ensure_future()

The full patch is ~300 lines. Happy to share it or submit a PR if that would be helpful.

Suggested fix

The minimal fix would be to ensure _run() wraps the main coroutine in an explicit asyncio.Task (e.g., via asyncio.run() instead of Runner.run()), so that asyncio.current_task() returns a valid task throughout Hypercorn's lifecycle.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions