Skip to content

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

Description

@Spikhalskiy

Bug report

Bug description:

_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

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions