Skip to content

Commit 7563195

Browse files
authored
gh-158159: Fix use-after-free in asyncio Task.get_name() (#158180)
1 parent 8dbc313 commit 7563195

3 files changed

Lines changed: 55 additions & 1 deletion

File tree

‎Lib/test/test_asyncio/test_tasks.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,52 @@ async def notmuch():
416416
self.assertEqual(t.get_name(), '123456789')
417417
self.loop.run_until_complete(t)
418418

419+
# The free-threaded GC publishes allocation counts in batches, so the
420+
# collection cannot be scheduled to run inside get_name().
421+
@support.requires_gil_enabled("needs precise GC scheduling")
422+
def test_task_get_name_reentered_during_formatting(self):
423+
# gh-158159: formatting the lazy default name can run the GC, and a
424+
# finalizer may call get_name() on the same task, replacing the name
425+
# the outer call is still formatting.
426+
loop = self.loop
427+
observed = {}
428+
keep = []
429+
task = None
430+
431+
class Reenter:
432+
def __init__(self):
433+
self.cycle = self
434+
435+
def __del__(self):
436+
observed['inner'] = task.get_name()
437+
# Reuse the memory of a counter that was freed too early.
438+
keep.extend(10**9 + i for i in range(8))
439+
440+
async def notmuch():
441+
pass
442+
443+
async def scenario():
444+
gc.collect()
445+
Reenter()
446+
old_threshold = gc.get_threshold()
447+
gc.set_threshold(1)
448+
try:
449+
scratch = [[0], [0], [0]] # schedule a collection
450+
observed['outer'] = task.get_name()
451+
finally:
452+
gc.set_threshold(*old_threshold)
453+
del scratch
454+
455+
# Names are only freeable once the counter is past the small ints.
456+
for _ in range(1100):
457+
loop.run_until_complete(self.new_task(loop, notmuch(), name=None))
458+
task = self.new_task(loop, scenario(), name=None)
459+
loop.run_until_complete(task)
460+
461+
self.assertRegex(observed['outer'], r'^Task-\d+$')
462+
self.assertEqual(observed['outer'], observed['inner'])
463+
self.assertEqual(task.get_name(), observed['outer'])
464+
419465
def test_task_repr_name_not_str(self):
420466
async def notmuch():
421467
return 123
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix a use-after-free in :meth:`asyncio.Task.get_name` when formatting the
2+
default task name triggers a garbage collection whose finalizers call
3+
:meth:`~asyncio.Task.get_name` on the same task.

‎Modules/_asynciomodule.c‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2803,7 +2803,12 @@ _asyncio_Task_get_name_impl(TaskObj *self)
28032803
{
28042804
if (self->task_name) {
28052805
if (PyLong_CheckExact(self->task_name)) {
2806-
PyObject *name = PyUnicode_FromFormat("Task-%S", self->task_name);
2806+
// Formatting can run arbitrary code (e.g. a GC finalizer that logs
2807+
// and re-enters get_name()), which may replace self->task_name.
2808+
// Hold a strong reference to the counter while formatting it.
2809+
PyObject *counter = Py_NewRef(self->task_name);
2810+
PyObject *name = PyUnicode_FromFormat("Task-%S", counter);
2811+
Py_DECREF(counter);
28072812
if (name == NULL) {
28082813
return NULL;
28092814
}

0 commit comments

Comments
 (0)