Skip to content

Use-after-free in asyncio Task.get_name() when a finalizer logs while the lazy name is formatted #158159

Description

@Spikhalskiy

Crash report

What happened?

_asyncio.Task.get_name() formats the lazy default name through a borrowed reference:

if (PyLong_CheckExact(self->task_name)) {
    PyObject *name = PyUnicode_FromFormat("Task-%S", self->task_name);
    ...
    Py_SETREF(self->task_name, name);
}

PyUnicode_FromFormat allocates, and that allocation can run the cyclic GC. If the collection finalizes a pending task, Task.__del__ logs Task was destroyed but it is pending!. Since 3.12, logging.LogRecord fills taskName by calling asyncio.current_task().get_name(). That is the same task, so get_name() re-enters, formats the name, and Py_SETREFs it. The PyLong the outer call is still formatting is freed.

Names above Task-256 are not immortal, so any long-running asyncio program that logs can hit this. The trigger is an abandoned pending task being collected while some task reads its name for the first time. In a large test suite this showed up as rare segfaults whose top frame is a plain Python call to get_name(). They were hard to attribute until we noticed every crash was on that line.

Reproducer (run with PYTHONMALLOC=debug to make the use-after-free deterministic; without it, it only crashes when the freed block happens to be reused mid-format):

import asyncio
import gc
import logging

logging.basicConfig()


async def wait_forever(fut):
    await fut


async def scenario():
    loop = asyncio.get_running_loop()
    current = asyncio.current_task()
    gc.collect()
    # A pending task reachable only through a task <-> future cycle; its
    # finalizer logs "Task was destroyed but it is pending!".
    orphan = loop.create_task(wait_forever(loop.create_future()))
    await asyncio.sleep(0)
    del orphan
    gc.set_threshold(1)
    scratch = [[0], [0], [0]]  # makes the next allocation run the GC
    current.get_name()  # formats the lazy name; the GC runs inside that
    gc.set_threshold(700)
    del scratch


async def main():
    # Names up to Task-256 are immortal small ints and cannot be freed.
    for _ in range(300):
        await asyncio.create_task(asyncio.sleep(0))
    await asyncio.create_task(scenario())


asyncio.run(main())
print("no crash")
$ PYTHONMALLOC=debug python3.12 -X faulthandler repro.py
ERROR:asyncio:Task was destroyed but it is pending!
task: <Task pending name='Task-303' coro=<wait_forever() done, ...>>
Fatal Python error: Segmentation fault

Current thread 0x... (most recent call first):
  File "repro.py", line 23 in scenario
  ...

Results:

  • 3.12.13 and 3.13.13 crash.
  • On 3.14.7 the re-entry still happens: the finalizer's LogRecord gets taskName='Task-302' from the nested get_name(). This reproducer just doesn't observe the freed read there. The code is unchanged on main.
  • Setting logging.logAsyncioTasks = False avoids it, which is our workaround for now.

A possible fix is to hold a strong reference while formatting, and keep whatever name a nested call may already have stored:

if (PyLong_CheckExact(self->task_name)) {
    PyObject *counter = Py_NewRef(self->task_name);
    PyObject *name = PyUnicode_FromFormat("Task-%S", counter);
    Py_DECREF(counter);
    if (name == NULL) {
        return NULL;
    }
    if (PyLong_CheckExact(self->task_name)) {
        Py_SETREF(self->task_name, name);
    }
    else {
        Py_DECREF(name);
    }
}
return Py_NewRef(self->task_name);

CPython versions tested on:

3.12, 3.13, 3.14

Operating systems tested on:

Linux, macOS

Output from running 'python -VV' on the command line:

Python 3.12.13 (main, Apr 7 2026, 21:09:58) [Clang 22.1.1 ]

Linked PRs

Activity

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

    extension-modulesC modules in the Modules dirtopic-asynciotype-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    • Status
      Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions