Repository navigation
Use-after-free in asyncio Task.get_name() when a finalizer logs while the lazy name is formatted #158159
Description
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
LogRecordgetstaskName='Task-302'from the nestedget_name(). This reproducer just doesn't observe the freed read there. The code is unchanged onmain. - Setting
logging.logAsyncioTasks = Falseavoids 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
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
picnixz commentedon Sep 25, 2026
I think we can temporarily hold a ref indeed. The path should be only executed once so it would not affect perf much.
picnixz commentedon Sep 25, 2026
I think we can just override 2hat is already there though. No need for a mutex or whatever. Just overwrite what has been formatted once