Skip to content

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

Open

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.

Activity

  1. seidnerj commented on Apr 12, 2026

    @seidnerj
    ContributorAuthor

    Additional issue: shutdown task cleanup in the worker_serve workaround

    We discovered another issue related to the Python 3.14 compatibility workaround described above.

    The original worker_serve shutdown logic (and our initial patched version) waits for in-flight server tasks to complete within the graceful timeout, but never cancels remaining tasks after the timeout expires. On Python 3.14, this causes orphaned Task objects to be garbage-collected while still pending, triggering:

    Task was destroyed but it is pending!
    task: <Task pending name='Task-10049' coro=<_worker_serve_patched.<locals>._server_callback() running at ...>
    

    This is logged as an error by asyncio's default exception handler.

    Fix: After the graceful timeout, explicitly cancel any unfinished server tasks and await them before proceeding to lifespan shutdown:

    if server_tasks:
        gathered_server_tasks = asyncio.gather(*server_tasks)
        try:
            await asyncio.wait(
                {asyncio.ensure_future(gathered_server_tasks)},
                timeout=config.graceful_timeout,
            )
        except Exception:
            pass
    
        # Cancel any tasks that didn't finish within the graceful timeout
        for task in server_tasks:
            if not task.done():
                task.cancel()
        await asyncio.gather(*server_tasks, return_exceptions=True)

    This may also affect the upstream worker_serve implementation (not just the workaround), since the original code has the same pattern of waiting with a timeout but not cancelling afterward.

  2. m-u-xyz commented on Jul 1, 2026

    @m-u-xyz
    Contributor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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